loading
Generated 2025-12-04T11:13:51+09:00

All Files ( 80.51% covered at 11.11 hits/line )

77 files in total.
7860 relevant lines, 6328 lines covered and 1532 lines missed. ( 80.51% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/cli/commands/build.rb 81.48 % 261 135 110 25 2.79
lib/cli/commands/generate.rb 83.20 % 419 244 203 41 3.77
lib/cli/commands/generate_xml.rb 59.21 % 158 76 45 31 1.28
lib/cli/commands/hotload.rb 40.82 % 211 98 40 58 1.69
lib/cli/commands/init.rb 71.03 % 274 107 76 31 4.69
lib/cli/commands/setup.rb 73.21 % 103 56 41 15 2.55
lib/cli/main.rb 89.29 % 71 28 25 3 2.89
lib/cli/version.rb 100.00 % 7 3 3 0 1.00
lib/compose/build_cache_manager.rb 74.73 % 173 91 68 23 4.00
lib/compose/components/blurview_component.rb 76.47 % 66 34 26 8 1.94
lib/compose/components/button_component.rb 98.99 % 188 99 98 1 10.99
lib/compose/components/checkbox_component.rb 56.76 % 134 74 42 32 3.18
lib/compose/components/circleimage_component.rb 100.00 % 101 53 53 0 15.70
lib/compose/components/collection_component.rb 99.52 % 345 208 207 1 17.83
lib/compose/components/constraintlayout_component.rb 92.25 % 283 142 131 11 10.68
lib/compose/components/container_component.rb 88.51 % 197 87 77 10 10.84
lib/compose/components/gradientview_component.rb 72.73 % 89 44 32 12 2.57
lib/compose/components/image_component.rb 100.00 % 75 39 39 0 8.18
lib/compose/components/indicator_component.rb 64.29 % 105 56 36 20 1.66
lib/compose/components/networkimage_component.rb 74.14 % 111 58 43 15 3.29
lib/compose/components/progress_component.rb 97.87 % 84 47 46 1 6.66
lib/compose/components/radio_component.rb 93.24 % 352 207 193 14 8.29
lib/compose/components/scrollview_component.rb 100.00 % 77 39 39 0 7.28
lib/compose/components/segment_component.rb 80.50 % 278 159 128 31 19.35
lib/compose/components/selectbox_component.rb 99.04 % 193 104 103 1 15.10
lib/compose/components/slider_component.rb 100.00 % 122 68 68 0 15.15
lib/compose/components/switch_component.rb 72.13 % 109 61 44 17 4.08
lib/compose/components/table_component.rb 100.00 % 175 108 108 0 24.69
lib/compose/components/tabview_component.rb 91.23 % 103 57 52 5 24.95
lib/compose/components/text_component.rb 71.92 % 572 292 210 82 11.70
lib/compose/components/textfield_component.rb 96.58 % 286 146 141 5 18.30
lib/compose/components/textview_component.rb 100.00 % 263 134 134 0 29.58
lib/compose/components/toggle_component.rb 100.00 % 89 49 49 0 9.16
lib/compose/components/web_component.rb 100.00 % 100 51 51 0 26.98
lib/compose/components/webview_component.rb 100.00 % 73 41 41 0 10.10
lib/compose/compose_builder.rb 64.30 % 695 395 254 141 3.01
lib/compose/data_model_updater.rb 86.78 % 450 227 197 30 7.74
lib/compose/generators/cell_generator.rb 96.77 % 344 93 90 3 9.02
lib/compose/generators/converter_generator.rb 79.03 % 401 124 98 26 3.44
lib/compose/generators/dynamic_component_generator.rb 64.90 % 464 151 98 53 2.36
lib/compose/generators/kotlin_component_generator.rb 83.64 % 265 110 92 18 4.47
lib/compose/generators/view_generator.rb 77.10 % 410 131 101 30 10.68
lib/compose/helpers/import_manager.rb 100.00 % 140 23 23 0 4.04
lib/compose/helpers/modifier_builder.rb 72.28 % 531 303 219 84 53.61
lib/compose/helpers/resource_resolver.rb 89.42 % 229 104 93 11 26.67
lib/compose/helpers/visibility_helper.rb 100.00 % 56 31 31 0 15.42
lib/compose/setup/compose_setup.rb 49.29 % 523 140 69 71 1.20
lib/compose/style_loader.rb 100.00 % 77 39 39 0 7.36
lib/core/attribute_validator.rb 67.61 % 307 142 96 46 35.81
lib/core/config_manager.rb 98.86 % 195 88 87 1 58.68
lib/core/json_loader.rb 100.00 % 46 17 17 0 7.76
lib/core/logger.rb 100.00 % 33 16 16 0 9.81
lib/core/project_finder.rb 93.22 % 142 59 55 4 3.85
lib/core/resources/color_manager.rb 82.39 % 642 318 262 56 5.83
lib/core/resources/string_manager.rb 61.60 % 478 237 146 91 4.15
lib/core/resources_manager.rb 100.00 % 82 45 45 0 6.78
lib/core/style_loader.rb 100.00 % 67 35 35 0 15.29
lib/hotloader/ip_monitor.rb 94.25 % 171 87 82 5 5.82
lib/xml/drawable/drawable_generator.rb 92.47 % 185 93 86 7 9.60
lib/xml/drawable/drawable_hash_manager.rb 47.76 % 152 67 32 35 6.78
lib/xml/drawable/ripple_drawable_generator.rb 96.00 % 192 100 96 4 7.31
lib/xml/drawable/shape_drawable_generator.rb 99.06 % 187 106 105 1 6.58
lib/xml/drawable/state_list_drawable_generator.rb 78.18 % 243 110 86 24 6.12
lib/xml/helpers/attribute_mapper.rb 92.11 % 188 76 70 6 13.24
lib/xml/helpers/binding_parser.rb 75.90 % 194 83 63 20 1.96
lib/xml/helpers/component_mapper.rb 93.94 % 152 33 31 2 5.55
lib/xml/helpers/data_binding_helper.rb 100.00 % 27 12 12 0 4.42
lib/xml/helpers/layout_attribute_processor.rb 76.34 % 192 93 71 22 5.38
lib/xml/helpers/mappers/dimension_mapper.rb 100.00 % 49 22 22 0 8.73
lib/xml/helpers/mappers/input_mapper.rb 95.24 % 106 42 40 2 2.57
lib/xml/helpers/mappers/layout_mapper.rb 62.96 % 228 108 68 40 2.93
lib/xml/helpers/mappers/style_mapper.rb 65.55 % 299 119 78 41 3.08
lib/xml/helpers/mappers/text_mapper.rb 93.06 % 161 72 67 5 4.86
lib/xml/helpers/resource_resolver.rb 72.16 % 214 97 70 27 13.90
lib/xml/resources/string_resource_manager.rb 37.80 % 211 82 31 51 3.50
lib/xml/xml_builder.rb 77.78 % 221 126 98 28 2.71
lib/xml/xml_generator.rb 74.16 % 437 209 155 54 3.95

Core ( 79.31% covered at 15.09 hits/line )

9 files in total.
957 relevant lines, 759 lines covered and 198 lines missed. ( 79.31% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/core/attribute_validator.rb 67.61 % 307 142 96 46 35.81
lib/core/config_manager.rb 98.86 % 195 88 87 1 58.68
lib/core/json_loader.rb 100.00 % 46 17 17 0 7.76
lib/core/logger.rb 100.00 % 33 16 16 0 9.81
lib/core/project_finder.rb 93.22 % 142 59 55 4 3.85
lib/core/resources/color_manager.rb 82.39 % 642 318 262 56 5.83
lib/core/resources/string_manager.rb 61.60 % 478 237 146 91 4.15
lib/core/resources_manager.rb 100.00 % 82 45 45 0 6.78
lib/core/style_loader.rb 100.00 % 67 35 35 0 15.29

CLI ( 72.69% covered at 3.06 hits/line )

8 files in total.
747 relevant lines, 543 lines covered and 204 lines missed. ( 72.69% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/cli/commands/build.rb 81.48 % 261 135 110 25 2.79
lib/cli/commands/generate.rb 83.20 % 419 244 203 41 3.77
lib/cli/commands/generate_xml.rb 59.21 % 158 76 45 31 1.28
lib/cli/commands/hotload.rb 40.82 % 211 98 40 58 1.69
lib/cli/commands/init.rb 71.03 % 274 107 76 31 4.69
lib/cli/commands/setup.rb 73.21 % 103 56 41 15 2.55
lib/cli/main.rb 89.29 % 71 28 25 3 2.89
lib/cli/version.rb 100.00 % 7 3 3 0 1.00

Compose ( 82.89% covered at 13.71 hits/line )

40 files in total.
4419 relevant lines, 3663 lines covered and 756 lines missed. ( 82.89% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/compose/build_cache_manager.rb 74.73 % 173 91 68 23 4.00
lib/compose/components/blurview_component.rb 76.47 % 66 34 26 8 1.94
lib/compose/components/button_component.rb 98.99 % 188 99 98 1 10.99
lib/compose/components/checkbox_component.rb 56.76 % 134 74 42 32 3.18
lib/compose/components/circleimage_component.rb 100.00 % 101 53 53 0 15.70
lib/compose/components/collection_component.rb 99.52 % 345 208 207 1 17.83
lib/compose/components/constraintlayout_component.rb 92.25 % 283 142 131 11 10.68
lib/compose/components/container_component.rb 88.51 % 197 87 77 10 10.84
lib/compose/components/gradientview_component.rb 72.73 % 89 44 32 12 2.57
lib/compose/components/image_component.rb 100.00 % 75 39 39 0 8.18
lib/compose/components/indicator_component.rb 64.29 % 105 56 36 20 1.66
lib/compose/components/networkimage_component.rb 74.14 % 111 58 43 15 3.29
lib/compose/components/progress_component.rb 97.87 % 84 47 46 1 6.66
lib/compose/components/radio_component.rb 93.24 % 352 207 193 14 8.29
lib/compose/components/scrollview_component.rb 100.00 % 77 39 39 0 7.28
lib/compose/components/segment_component.rb 80.50 % 278 159 128 31 19.35
lib/compose/components/selectbox_component.rb 99.04 % 193 104 103 1 15.10
lib/compose/components/slider_component.rb 100.00 % 122 68 68 0 15.15
lib/compose/components/switch_component.rb 72.13 % 109 61 44 17 4.08
lib/compose/components/table_component.rb 100.00 % 175 108 108 0 24.69
lib/compose/components/tabview_component.rb 91.23 % 103 57 52 5 24.95
lib/compose/components/text_component.rb 71.92 % 572 292 210 82 11.70
lib/compose/components/textfield_component.rb 96.58 % 286 146 141 5 18.30
lib/compose/components/textview_component.rb 100.00 % 263 134 134 0 29.58
lib/compose/components/toggle_component.rb 100.00 % 89 49 49 0 9.16
lib/compose/components/web_component.rb 100.00 % 100 51 51 0 26.98
lib/compose/components/webview_component.rb 100.00 % 73 41 41 0 10.10
lib/compose/compose_builder.rb 64.30 % 695 395 254 141 3.01
lib/compose/data_model_updater.rb 86.78 % 450 227 197 30 7.74
lib/compose/generators/cell_generator.rb 96.77 % 344 93 90 3 9.02
lib/compose/generators/converter_generator.rb 79.03 % 401 124 98 26 3.44
lib/compose/generators/dynamic_component_generator.rb 64.90 % 464 151 98 53 2.36
lib/compose/generators/kotlin_component_generator.rb 83.64 % 265 110 92 18 4.47
lib/compose/generators/view_generator.rb 77.10 % 410 131 101 30 10.68
lib/compose/helpers/import_manager.rb 100.00 % 140 23 23 0 4.04
lib/compose/helpers/modifier_builder.rb 72.28 % 531 303 219 84 53.61
lib/compose/helpers/resource_resolver.rb 89.42 % 229 104 93 11 26.67
lib/compose/helpers/visibility_helper.rb 100.00 % 56 31 31 0 15.42
lib/compose/setup/compose_setup.rb 49.29 % 523 140 69 71 1.20
lib/compose/style_loader.rb 100.00 % 77 39 39 0 7.36

XML ( 77.64% covered at 5.75 hits/line )

19 files in total.
1650 relevant lines, 1281 lines covered and 369 lines missed. ( 77.64% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/xml/drawable/drawable_generator.rb 92.47 % 185 93 86 7 9.60
lib/xml/drawable/drawable_hash_manager.rb 47.76 % 152 67 32 35 6.78
lib/xml/drawable/ripple_drawable_generator.rb 96.00 % 192 100 96 4 7.31
lib/xml/drawable/shape_drawable_generator.rb 99.06 % 187 106 105 1 6.58
lib/xml/drawable/state_list_drawable_generator.rb 78.18 % 243 110 86 24 6.12
lib/xml/helpers/attribute_mapper.rb 92.11 % 188 76 70 6 13.24
lib/xml/helpers/binding_parser.rb 75.90 % 194 83 63 20 1.96
lib/xml/helpers/component_mapper.rb 93.94 % 152 33 31 2 5.55
lib/xml/helpers/data_binding_helper.rb 100.00 % 27 12 12 0 4.42
lib/xml/helpers/layout_attribute_processor.rb 76.34 % 192 93 71 22 5.38
lib/xml/helpers/mappers/dimension_mapper.rb 100.00 % 49 22 22 0 8.73
lib/xml/helpers/mappers/input_mapper.rb 95.24 % 106 42 40 2 2.57
lib/xml/helpers/mappers/layout_mapper.rb 62.96 % 228 108 68 40 2.93
lib/xml/helpers/mappers/style_mapper.rb 65.55 % 299 119 78 41 3.08
lib/xml/helpers/mappers/text_mapper.rb 93.06 % 161 72 67 5 4.86
lib/xml/helpers/resource_resolver.rb 72.16 % 214 97 70 27 13.90
lib/xml/resources/string_resource_manager.rb 37.80 % 211 82 31 51 3.50
lib/xml/xml_builder.rb 77.78 % 221 126 98 28 2.71
lib/xml/xml_generator.rb 74.16 % 437 209 155 54 3.95

Ungrouped ( 94.25% covered at 5.82 hits/line )

1 files in total.
87 relevant lines, 82 lines covered and 5 lines missed. ( 94.25% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/hotloader/ip_monitor.rb 94.25 % 171 87 82 5 5.82

lib/cli/commands/build.rb

81.48% lines covered

135 relevant lines. 110 lines covered and 25 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'optparse'
  3. 1 require 'json'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 require_relative '../../core/logger'
  7. 1 require_relative '../../core/attribute_validator'
  8. 1 module KjuiTools
  9. 1 module CLI
  10. 1 module Commands
  11. 1 class Build
  12. 1 def run(args)
  13. 8 options = parse_options(args)
  14. # Detect mode
  15. 8 mode = options[:mode] || Core::ConfigManager.get('mode') || 'compose'
  16. # Store validation results
  17. 8 @validation_warnings = []
  18. 8 @validation_errors = 0
  19. 8 case mode
  20. when 'xml', 'all'
  21. 1 build_xml(options)
  22. end
  23. 8 if mode == 'compose' || mode == 'all'
  24. 7 build_compose(options)
  25. end
  26. # Print validation summary if there were warnings
  27. 8 print_validation_summary if options[:validate] && @validation_warnings.any?
  28. # Exit with error code if strict mode and there were validation errors
  29. 8 if options[:strict] && @validation_errors > 0
  30. Core::Logger.error "Build failed: #{@validation_errors} validation error(s)"
  31. exit 1
  32. end
  33. end
  34. 1 private
  35. 1 def parse_options(args)
  36. 8 options = {}
  37. 8 OptionParser.new do |opts|
  38. 8 opts.banner = "Usage: kjui build [options]"
  39. 8 opts.on('--mode MODE', ['all', 'xml', 'compose'],
  40. 'Build mode (all, xml, compose)') do |mode|
  41. 2 options[:mode] = mode
  42. end
  43. 8 opts.on('--clean', 'Clean cache before building') do
  44. 1 options[:clean] = true
  45. end
  46. 8 opts.on('--validate', 'Validate JSON attributes against schema') do
  47. 2 options[:validate] = true
  48. end
  49. 8 opts.on('--strict', 'Fail build on validation errors') do
  50. 1 options[:strict] = true
  51. 1 options[:validate] = true
  52. end
  53. 8 opts.on('-h', '--help', 'Show this help message') do
  54. puts opts
  55. exit
  56. end
  57. end.parse!(args)
  58. 8 options
  59. end
  60. 1 def print_validation_summary
  61. 1 Core::Logger.info "-" * 60
  62. 1 Core::Logger.warn "Validation Summary: #{@validation_warnings.length} warning(s) found"
  63. 1 @validation_warnings.each do |warning|
  64. 1 puts " \e[33m#{warning}\e[0m"
  65. end
  66. end
  67. # Validate a JSON component and all its children recursively
  68. 1 def validate_json(json_data, validator, file_name)
  69. 2 return [] unless json_data.is_a?(Hash)
  70. 2 warnings = validator.validate(json_data)
  71. # Validate children recursively
  72. 2 children = json_data['child'] || json_data['children'] || []
  73. 2 children = [children] unless children.is_a?(Array)
  74. 2 children.each do |child|
  75. 1 warnings.concat(validate_json(child, validator, file_name)) if child.is_a?(Hash)
  76. end
  77. # Validate sections (for Collection/Table)
  78. 2 if json_data['sections'].is_a?(Array)
  79. json_data['sections'].each do |section|
  80. if section.is_a?(Hash)
  81. ['header', 'footer', 'cell'].each do |key|
  82. warnings.concat(validate_json(section[key], validator, file_name)) if section[key].is_a?(Hash)
  83. end
  84. end
  85. end
  86. end
  87. 2 warnings
  88. end
  89. 1 def build_xml(options = {})
  90. Core::Logger.info "Building XML View files..."
  91. # Setup project paths
  92. Core::ProjectFinder.setup_paths
  93. require_relative '../../xml/xml_builder'
  94. builder = Xml::XmlBuilder.new
  95. # Pass validation options to builder
  96. builder.validation_enabled = options[:validate]
  97. builder.validation_callback = ->(file, warnings) {
  98. if warnings.any?
  99. @validation_warnings.concat(warnings.map { |w| "[#{file}] #{w}" })
  100. @validation_errors += warnings.length
  101. end
  102. } if options[:validate]
  103. builder.build(options)
  104. Core::Logger.success "XML build completed!"
  105. end
  106. 1 def build_compose(options = {})
  107. 7 Core::Logger.info "Building Compose files..."
  108. # Setup project paths
  109. 7 Core::ProjectFinder.setup_paths
  110. 7 require_relative '../../compose/compose_builder'
  111. 7 require_relative '../../compose/build_cache_manager'
  112. 7 config = Core::ConfigManager.load_config
  113. 7 source_path = Core::ProjectFinder.get_full_source_path || Dir.pwd
  114. 7 source_directory = config['source_directory'] || 'src/main'
  115. 7 layouts_dir = File.join(source_path, source_directory, config['layouts_directory'] || 'assets/Layouts')
  116. # Initialize cache manager
  117. 7 cache_manager = Compose::BuildCacheManager.new(source_path)
  118. # Clean cache if --clean option is specified
  119. 7 if options[:clean]
  120. 1 Core::Logger.info "Cleaning build cache..."
  121. 1 cache_manager.clean_cache
  122. end
  123. 7 last_updated = cache_manager.load_last_updated
  124. 7 last_including_files = cache_manager.load_last_including_files
  125. 7 style_dependencies = cache_manager.load_style_dependencies
  126. # Process all JSON files in Layouts directory (excluding Resources folder)
  127. 7 json_files = Dir.glob(File.join(layouts_dir, '**/*.json')).reject do |file|
  128. 2 file.include?('/Resources/')
  129. end
  130. 7 if json_files.empty?
  131. 5 Core::Logger.warn "No JSON files found in #{layouts_dir}"
  132. 5 return
  133. end
  134. # Extract resources before processing layouts
  135. 2 require_relative '../../core/resources_manager'
  136. 2 resources_manager = Core::ResourcesManager.new(config, source_path)
  137. 2 resources_manager.extract_resources(json_files)
  138. 2 Core::Logger.info "-" * 60
  139. # Track new includes and style dependencies
  140. 2 new_including_files = {}
  141. 2 new_style_dependencies = {}
  142. # Filter files that need update
  143. 2 files_to_update = []
  144. 2 json_files.each do |json_file|
  145. 2 file_name = File.basename(json_file, '.json')
  146. # Check if file needs update
  147. 2 if cache_manager.needs_update?(json_file, last_updated, layouts_dir, last_including_files, style_dependencies)
  148. 2 files_to_update << json_file
  149. else
  150. # Keep existing includes and style dependencies for unchanged files
  151. new_including_files[file_name] = last_including_files[file_name] if last_including_files[file_name]
  152. new_style_dependencies[file_name] = style_dependencies[file_name] if style_dependencies[file_name]
  153. end
  154. end
  155. 2 if files_to_update.empty?
  156. Core::Logger.info "No files need updating (all cached)"
  157. return
  158. end
  159. 2 Core::Logger.info "Updating #{files_to_update.length} of #{json_files.length} files..."
  160. # Update data models first
  161. 2 require_relative '../../compose/data_model_updater'
  162. 2 data_updater = Compose::DataModelUpdater.new
  163. 2 data_updater.update_data_models
  164. # Initialize validator if validation is enabled
  165. 2 validator = options[:validate] ? Core::AttributeValidator.new(:compose) : nil
  166. 2 builder = Compose::ComposeBuilder.new
  167. 2 files_to_update.each do |json_file|
  168. 2 relative_path = Pathname.new(json_file).relative_path_from(Pathname.new(layouts_dir)).to_s
  169. 2 file_name = File.basename(json_file, '.json')
  170. begin
  171. # Read and parse JSON
  172. 2 json_content = File.read(json_file)
  173. 2 json_data = JSON.parse(json_content)
  174. # Validate if enabled
  175. 2 if validator
  176. 1 warnings = validate_json(json_data, validator, file_name)
  177. 1 if warnings.any?
  178. 2 @validation_warnings.concat(warnings.map { |w| "[#{relative_path}] #{w}" })
  179. 1 @validation_errors += warnings.length
  180. 1 Core::Logger.warn " #{warnings.length} validation warning(s) in #{relative_path}"
  181. end
  182. end
  183. # Extract includes and styles for cache tracking
  184. 2 includes = cache_manager.extract_includes(json_data)
  185. 2 styles = cache_manager.extract_styles(json_data)
  186. 2 new_including_files[file_name] = includes if includes.any?
  187. 2 new_style_dependencies[file_name] = styles if styles.any?
  188. # Build Compose file
  189. 2 Core::Logger.info "Processing: #{relative_path}"
  190. 2 builder.build_file(json_file)
  191. rescue JSON::ParserError => e
  192. Core::Logger.error "Failed to parse #{json_file}: #{e.message}"
  193. rescue => e
  194. Core::Logger.error "Failed to process #{json_file}: #{e.message}"
  195. end
  196. end
  197. # Save cache for next build
  198. 2 cache_manager.save_cache(new_including_files, new_style_dependencies)
  199. 2 Core::Logger.success "Compose build completed!"
  200. end
  201. end
  202. end
  203. end
  204. end

lib/cli/commands/generate.rb

83.2% lines covered

244 relevant lines. 203 lines covered and 41 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'optparse'
  3. 1 require_relative '../../core/config_manager'
  4. 1 require_relative '../../core/project_finder'
  5. 1 module KjuiTools
  6. 1 module CLI
  7. 1 module Commands
  8. 1 class Generate
  9. 1 SUBCOMMANDS = {
  10. 'view' => 'Generate a new view with JSON and binding',
  11. 'partial' => 'Generate a partial view',
  12. 'collection' => 'Generate a collection view',
  13. 'cell' => 'Generate a collection cell view',
  14. 'binding' => 'Generate binding file',
  15. 'converter' => 'Generate a custom component converter'
  16. }.freeze
  17. 1 def run(args)
  18. # Parse global options first
  19. 25 global_options = parse_global_options(args)
  20. 25 subcommand = args.shift
  21. # Load config to get default mode
  22. 25 config = Core::ConfigManager.load_config
  23. # Use mode from options if provided, otherwise from config, otherwise default to compose
  24. 25 mode = global_options[:mode] || config['mode'] || 'compose'
  25. # If no subcommand, generate all based on mode
  26. 25 if subcommand.nil?
  27. 6 if mode == 'xml'
  28. 1 generate_all_xml_layouts(config)
  29. else
  30. 5 generate_all_compose_views(config)
  31. end
  32. 6 return
  33. end
  34. 19 if subcommand == 'help' || subcommand == '--help' || subcommand == '-h'
  35. 2 show_help
  36. 2 return
  37. end
  38. 17 unless SUBCOMMANDS.key?(subcommand)
  39. # Check if it's a layout name (no subcommand, just generate that layout)
  40. 3 if mode == 'xml' && !subcommand.start_with?('-')
  41. 1 generate_specific_xml_layout(subcommand, args, config)
  42. 1 return
  43. 2 elsif !subcommand.start_with?('-')
  44. # For compose mode, treat it as a layout name and build it
  45. 1 puts "Building layout: #{subcommand}"
  46. 1 generate_specific_compose_layout(subcommand, args, config)
  47. 1 return
  48. end
  49. 1 puts "Unknown generate command: #{subcommand}"
  50. 1 show_help
  51. 1 exit 1
  52. end
  53. 14 case subcommand
  54. when 'view'
  55. 4 generate_view(args, mode)
  56. when 'partial'
  57. 1 generate_partial(args, mode)
  58. when 'collection'
  59. 2 generate_collection(args, mode)
  60. when 'cell'
  61. 3 generate_cell(args, mode)
  62. when 'binding'
  63. 2 generate_binding(args, mode)
  64. when 'converter'
  65. 2 generate_converter(args, mode)
  66. end
  67. end
  68. 1 private
  69. 1 def parse_global_options(args)
  70. 29 options = { mode: nil }
  71. # Look for mode option and remove it from args
  72. 29 args.each_with_index do |arg, index|
  73. 30 if arg == '--mode' || arg == '-m'
  74. 9 if args[index + 1]
  75. 9 options[:mode] = args[index + 1]
  76. 9 args.delete_at(index + 1)
  77. 9 args.delete_at(index)
  78. 9 break
  79. end
  80. 21 elsif arg.start_with?('--mode=')
  81. 2 options[:mode] = arg.split('=', 2)[1]
  82. 2 args.delete_at(index)
  83. 2 break
  84. end
  85. end
  86. 29 options
  87. end
  88. 1 def generate_view(args, mode)
  89. 4 options = parse_view_options(args)
  90. 4 name = args.shift
  91. 4 if name.nil? || name.empty?
  92. 1 puts "Error: View name is required"
  93. 1 puts "Usage: kjui generate view <name> [options]"
  94. 1 exit 1
  95. end
  96. # Setup project paths
  97. 3 Core::ProjectFinder.setup_paths
  98. 3 case mode
  99. when 'xml'
  100. require_relative '../../xml/generators/view_generator'
  101. generator = KjuiTools::Xml::Generators::ViewGenerator.new(name, options)
  102. generator.generate
  103. when 'compose'
  104. 2 require_relative '../../compose/generators/view_generator'
  105. 2 generator = KjuiTools::Compose::Generators::ViewGenerator.new(name, options)
  106. 2 generator.generate
  107. else
  108. 1 puts "Error: Unknown mode: #{mode}"
  109. 1 exit 1
  110. end
  111. end
  112. 1 def generate_partial(args, mode)
  113. 1 name = args.shift
  114. 1 if name.nil? || name.empty?
  115. 1 puts "Error: Partial name is required"
  116. 1 puts "Usage: kjui generate partial <name>"
  117. 1 exit 1
  118. end
  119. case mode
  120. when 'xml'
  121. require_relative '../../xml/generators/partial_generator'
  122. generator = KjuiTools::Xml::Generators::PartialGenerator.new(name)
  123. generator.generate
  124. when 'compose'
  125. require_relative '../../compose/generators/partial_generator'
  126. generator = KjuiTools::Compose::Generators::PartialGenerator.new(name)
  127. generator.generate
  128. end
  129. end
  130. 1 def generate_collection(args, mode)
  131. 2 name = args.shift
  132. 2 if name.nil? || name.empty?
  133. 1 puts "Error: Collection name is required"
  134. 1 puts "Usage: kjui generate collection <name>"
  135. 1 exit 1
  136. end
  137. # Setup project paths
  138. 1 Core::ProjectFinder.setup_paths
  139. 1 case mode
  140. when 'xml'
  141. require_relative '../../xml/generators/collection_generator'
  142. generator = KjuiTools::Xml::Generators::CollectionGenerator.new(name)
  143. generator.generate
  144. when 'compose'
  145. require_relative '../../compose/generators/collection_generator'
  146. generator = KjuiTools::Compose::Generators::CollectionGenerator.new(name)
  147. generator.generate
  148. else
  149. 1 puts "Error: Unknown mode: #{mode}"
  150. 1 exit 1
  151. end
  152. end
  153. 1 def generate_cell(args, mode)
  154. 3 name = args.shift
  155. 3 if name.nil? || name.empty?
  156. 1 puts "Error: Cell name is required"
  157. 1 puts "Usage: kjui generate cell <name>"
  158. 1 exit 1
  159. end
  160. # Setup project paths
  161. 2 Core::ProjectFinder.setup_paths
  162. 2 case mode
  163. when 'xml'
  164. 1 puts "Cell generation is not available in XML mode"
  165. 1 exit 1
  166. when 'compose'
  167. require_relative '../../compose/generators/cell_generator'
  168. generator = KjuiTools::Compose::Generators::CellGenerator.new(name)
  169. generator.generate
  170. else
  171. 1 puts "Error: Unknown mode: #{mode}"
  172. 1 exit 1
  173. end
  174. end
  175. 1 def generate_binding(args, mode)
  176. 2 name = args.shift
  177. 2 if name.nil? || name.empty?
  178. 1 puts "Error: Binding name is required"
  179. 1 puts "Usage: kjui generate binding <name>"
  180. 1 exit 1
  181. end
  182. 1 if mode != 'xml'
  183. 1 puts "Binding generation is only available in XML mode"
  184. 1 exit 1
  185. end
  186. require_relative '../../xml/generators/binding_generator'
  187. generator = KjuiTools::Xml::Generators::BindingGenerator.new(name)
  188. generator.generate
  189. end
  190. 1 def generate_converter(args, mode)
  191. 2 unless mode == 'compose'
  192. 1 puts "Converter generation is only available in Compose mode"
  193. 1 exit 1
  194. end
  195. 1 name = args.shift
  196. 1 unless name
  197. 1 puts "Error: Please provide a component name"
  198. 1 puts "Usage: kjui generate converter <ComponentName> [options]"
  199. 1 puts "Options:"
  200. 1 puts " --container Generate as container component"
  201. 1 puts " --no-container Generate as non-container component"
  202. 1 puts " --attr KEY:TYPE Add attribute (can be used multiple times)"
  203. 1 puts " --binding KEY:TYPE Add binding attribute"
  204. 1 puts
  205. 1 puts "Examples:"
  206. 1 puts " kjui g converter MyCard --container"
  207. 1 puts " kjui g converter StatusBadge --attr text:String --attr color:Color"
  208. 1 puts " kjui g converter DataCard --binding title:String --attr icon:String"
  209. 1 exit 1
  210. end
  211. options = parse_converter_options(args)
  212. require_relative '../../compose/generators/converter_generator'
  213. generator = KjuiTools::Compose::Generators::ConverterGenerator.new(name, options)
  214. generator.generate
  215. end
  216. 1 def parse_converter_options(args)
  217. options = {
  218. 7 is_container: nil,
  219. attributes: {}
  220. }
  221. # Parse flags first
  222. 7 parser = OptionParser.new do |opts|
  223. 7 opts.on('--container', 'Generate as container component') do
  224. 1 options[:is_container] = true
  225. end
  226. 7 opts.on('--no-container', 'Generate as non-container component') do
  227. 1 options[:is_container] = false
  228. end
  229. 7 opts.on('--attr KEY:TYPE', 'Add attribute') do |attr|
  230. 3 key, type = attr.split(':')
  231. 3 if key && type
  232. 3 options[:attributes][key] = type
  233. else
  234. puts "Invalid attribute format. Use KEY:TYPE (e.g., text:String)"
  235. exit 1
  236. end
  237. end
  238. 7 opts.on('--binding KEY:TYPE', 'Add binding attribute') do |attr|
  239. 1 key, type = attr.split(':')
  240. 1 if key && type
  241. # Prefix with @ to indicate binding
  242. 1 options[:attributes]["@#{key}"] = type
  243. else
  244. puts "Invalid binding format. Use KEY:TYPE (e.g., title:String)"
  245. exit 1
  246. end
  247. end
  248. end
  249. 7 parser.parse!(args)
  250. # Parse remaining arguments as attributes (simplified syntax)
  251. 7 args.each do |arg|
  252. 3 if arg.include?(':')
  253. 3 key, type = arg.split(':', 2)
  254. 3 if key && type
  255. # Check if it's a binding (starts with @)
  256. 3 if key.start_with?('@')
  257. 1 options[:attributes][key] = type
  258. else
  259. 2 options[:attributes][key] = type
  260. end
  261. end
  262. end
  263. end
  264. 7 options
  265. end
  266. 1 def parse_view_options(args)
  267. 11 options = {
  268. root: false,
  269. mode: nil,
  270. type: nil,
  271. force: false
  272. }
  273. 11 OptionParser.new do |opts|
  274. 11 opts.on('--root', 'Generate root view/activity') do
  275. 1 options[:root] = true
  276. end
  277. 11 opts.on('--mode MODE', 'Override mode (xml, compose)') do |mode|
  278. 1 options[:mode] = mode
  279. end
  280. 11 opts.on('--type TYPE', 'View type for XML mode (activity, fragment)') do |type|
  281. 1 options[:type] = type
  282. end
  283. 11 opts.on('--activity', 'Generate as Activity (XML mode)') do
  284. 1 options[:type] = 'activity'
  285. end
  286. 11 opts.on('--fragment', 'Generate as Fragment (XML mode)') do
  287. 1 options[:type] = 'fragment'
  288. end
  289. 11 opts.on('-f', '--force', 'Force overwrite existing files') do
  290. 2 options[:force] = true
  291. end
  292. end.parse!(args)
  293. 11 options
  294. end
  295. 1 def generate_all_xml_layouts(config)
  296. require_relative '../../xml/xml_generator'
  297. require_relative '../commands/generate_xml'
  298. puts "Generating all XML layouts..."
  299. CLI::Commands::GenerateXml.run([])
  300. end
  301. 1 def generate_all_compose_views(config)
  302. 5 require_relative '../../compose/compose_builder'
  303. 5 puts "Generating all Compose views..."
  304. # Call the existing Compose builder
  305. 5 system("ruby #{File.join(File.dirname(__FILE__), '../../..', 'bin', 'kjui')} build")
  306. end
  307. 1 def generate_specific_xml_layout(layout_name, args, config)
  308. require_relative '../../xml/xml_generator'
  309. require_relative '../commands/generate_xml'
  310. puts "Generating XML for layout: #{layout_name}"
  311. CLI::Commands::GenerateXml.run([layout_name] + args)
  312. end
  313. 1 def generate_specific_compose_layout(layout_name, args, config)
  314. require_relative '../../compose/compose_builder'
  315. puts "Building Compose layout: #{layout_name}"
  316. # TODO: Implement single layout generation for compose
  317. system("ruby #{File.join(File.dirname(__FILE__), '../../..', 'bin', 'kjui')} build")
  318. end
  319. 1 def show_help
  320. 5 puts "Usage: kjui generate [SUBCOMMAND] [options]"
  321. 5 puts
  322. 5 puts "Global Options:"
  323. 5 puts " --mode, -m MODE Override mode (xml/compose)"
  324. 5 puts " Default: use config.json mode"
  325. 5 puts
  326. 5 puts "When in XML mode:"
  327. 5 puts " kjui generate # Generate all XML layouts"
  328. 5 puts " kjui generate test_menu # Generate specific XML layout"
  329. 5 puts
  330. 5 puts "When in Compose mode:"
  331. 5 puts " kjui generate # Generate all Compose views"
  332. 5 puts
  333. 5 puts "Subcommands:"
  334. 5 SUBCOMMANDS.each do |cmd, desc|
  335. 30 puts " #{cmd.ljust(12)} #{desc}"
  336. end
  337. 5 puts
  338. 5 puts "View Options (XML mode):"
  339. 5 puts " --activity Generate as Activity (default)"
  340. 5 puts " --fragment Generate as Fragment"
  341. 5 puts " --type TYPE Specify type (activity/fragment)"
  342. 5 puts " -f, --force Force overwrite existing files"
  343. 5 puts
  344. 5 puts "Examples:"
  345. 5 puts " kjui g # Generate all (based on config mode)"
  346. 5 puts " kjui g --mode xml # Generate all XML layouts"
  347. 5 puts " kjui g --mode compose # Generate all Compose views"
  348. 5 puts " kjui g view HomeView --mode xml --activity # Generate Activity"
  349. 5 puts " kjui g view ProfileView --mode xml --fragment # Generate Fragment"
  350. 5 puts " kjui g view MainView --mode compose # Generate Compose view"
  351. 5 puts " kjui g converter MyCard --container # Generate custom component"
  352. end
  353. end
  354. end
  355. end
  356. end

lib/cli/commands/generate_xml.rb

59.21% lines covered

76 relevant lines. 45 lines covered and 31 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require_relative '../../core/config_manager'
  3. 1 require_relative '../../xml/xml_generator'
  4. 1 module CLI
  5. 1 module Commands
  6. 1 class GenerateXml
  7. 1 def self.run(args)
  8. 5 puts "🔧 KotlinJsonUI XML Generator"
  9. 5 puts "=============================="
  10. # Load configuration
  11. 5 config = ConfigManager.load_config
  12. 5 if config.nil?
  13. 1 puts "❌ Error: config.json not found"
  14. 1 puts "Run 'kjui init --mode xml' first to create configuration"
  15. 1 return 1
  16. end
  17. # Check if XML mode is configured
  18. 4 if config['mode'] != 'xml'
  19. 1 puts "❌ Error: Project is configured for #{config['mode']} mode, not XML"
  20. 1 puts "Run 'kjui init --mode xml' to reconfigure for XML mode"
  21. 1 return 1
  22. end
  23. # Parse arguments
  24. 3 layout_name = nil
  25. 3 force = false
  26. 3 i = 0
  27. 3 while i < args.length
  28. 2 case args[i]
  29. when '--layout', '-l'
  30. layout_name = args[i + 1]
  31. i += 1
  32. when '--force', '-f'
  33. force = true
  34. when '--help', '-h'
  35. 2 show_help
  36. 2 return 0
  37. else
  38. if layout_name.nil? && !args[i].start_with?('-')
  39. layout_name = args[i]
  40. end
  41. end
  42. i += 1
  43. end
  44. 1 if layout_name.nil?
  45. # Generate all layouts
  46. 1 generate_all_layouts(config, force)
  47. else
  48. # Generate specific layout
  49. generate_layout(layout_name, config, force)
  50. end
  51. 1 0
  52. rescue => e
  53. puts "❌ Error: #{e.message}"
  54. puts e.backtrace if ENV['DEBUG']
  55. 1
  56. end
  57. 1 private
  58. 1 def self.generate_all_layouts(config, force)
  59. 1 layouts_dir = File.join(config['project_path'], 'app', 'src', 'main', 'assets', 'Layouts')
  60. 1 unless Dir.exist?(layouts_dir)
  61. puts "❌ Error: Layouts directory not found: #{layouts_dir}"
  62. return
  63. end
  64. 1 json_files = Dir.glob(File.join(layouts_dir, '*.json'))
  65. 1 if json_files.empty?
  66. 1 puts "❌ No JSON layout files found in #{layouts_dir}"
  67. 1 return
  68. end
  69. puts "Found #{json_files.length} layout file(s)"
  70. puts ""
  71. success_count = 0
  72. json_files.each do |json_file|
  73. layout_name = File.basename(json_file, '.json')
  74. if should_generate?(layout_name, config, force)
  75. generator = XmlGenerator::Generator.new(layout_name, config)
  76. if generator.generate
  77. success_count += 1
  78. end
  79. else
  80. puts "⏭️ Skipping #{layout_name} (up to date)"
  81. end
  82. end
  83. puts ""
  84. puts "✅ Successfully generated #{success_count} XML layout(s)"
  85. end
  86. 1 def self.generate_layout(layout_name, config, force)
  87. # Remove .json extension if present
  88. layout_name = layout_name.sub(/\.json$/, '')
  89. if should_generate?(layout_name, config, force)
  90. generator = XmlGenerator::Generator.new(layout_name, config)
  91. if generator.generate
  92. puts "✅ Successfully generated XML for #{layout_name}"
  93. else
  94. puts "❌ Failed to generate XML for #{layout_name}"
  95. end
  96. else
  97. puts "⏭️ Layout #{layout_name} is up to date (use --force to regenerate)"
  98. end
  99. end
  100. 1 def self.should_generate?(layout_name, config, force)
  101. 5 return true if force
  102. # Check modification times
  103. 4 json_file = File.join(config['project_path'], 'app', 'src', 'main', 'assets', 'Layouts', "#{layout_name}.json")
  104. 4 xml_file = File.join(config['project_path'], 'app', 'src', 'main', 'res', 'layout', "#{layout_name.downcase}.xml")
  105. 4 return true unless File.exist?(xml_file)
  106. 2 return true unless File.exist?(json_file)
  107. 2 File.mtime(json_file) > File.mtime(xml_file)
  108. end
  109. 1 def self.show_help
  110. 8 puts <<~HELP
  111. Usage: kjui generate-xml [layout_name] [options]
  112. Generate Android XML layouts from JSON files
  113. Arguments:
  114. layout_name Name of the layout to generate (optional)
  115. If not specified, generates all layouts
  116. Options:
  117. -l, --layout <name> Specify layout name
  118. -f, --force Force regeneration even if up to date
  119. -h, --help Show this help message
  120. Examples:
  121. kjui generate-xml # Generate all layouts
  122. kjui generate-xml test_menu # Generate specific layout
  123. kjui generate-xml -f # Force regenerate all
  124. kjui generate-xml test_menu -f # Force regenerate specific layout
  125. HELP
  126. end
  127. end
  128. end
  129. end

lib/cli/commands/hotload.rb

40.82% lines covered

98 relevant lines. 40 lines covered and 58 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'fileutils'
  3. 1 require 'json'
  4. 1 require 'open3'
  5. 1 require_relative '../../hotloader/ip_monitor'
  6. 1 module KjuiTools
  7. 1 module CLI
  8. 1 module Commands
  9. 1 class Hotload
  10. 1 def self.run(args)
  11. 6 command = args.first
  12. 6 case command
  13. when 'start', 'listen'
  14. 2 start_hotloader
  15. when 'stop'
  16. 1 stop_hotloader
  17. when 'status'
  18. 1 show_status
  19. else
  20. 2 show_help
  21. end
  22. end
  23. 1 private
  24. 1 def self.start_hotloader
  25. puts "Starting KotlinJsonUI HotLoader..."
  26. puts "================================="
  27. # Check if Node.js is installed
  28. unless system('which node > /dev/null 2>&1')
  29. puts "Error: Node.js is not installed. Please install Node.js first."
  30. puts "Visit: https://nodejs.org/"
  31. exit 1
  32. end
  33. # Find project root
  34. project_root = find_project_root
  35. hotloader_dir = File.join(File.dirname(__FILE__), '../../hotloader')
  36. # Install npm dependencies if needed
  37. Dir.chdir(hotloader_dir) do
  38. unless Dir.exist?('node_modules')
  39. puts "Installing dependencies..."
  40. system('npm install')
  41. end
  42. end
  43. # Kill any existing processes on port 8081
  44. kill_port_process(8081)
  45. # Start IP monitor
  46. ip_monitor = KjuiTools::Hotloader::IpMonitor.new(project_root)
  47. ip_monitor.start
  48. # Get current IP
  49. ip = get_local_ip
  50. puts "\nLocal IP: #{ip}"
  51. puts "Port: 8081"
  52. # Start Node.js server
  53. puts "\nStarting server..."
  54. Dir.chdir(hotloader_dir) do
  55. ENV['HOST'] = '0.0.0.0'
  56. ENV['PORT'] = '8081'
  57. # Start server in foreground
  58. system('node server.js')
  59. end
  60. # Stop IP monitor when server stops
  61. ip_monitor.stop
  62. end
  63. 1 def self.stop_hotloader
  64. puts "Stopping KotlinJsonUI HotLoader..."
  65. # Kill Node.js server
  66. kill_port_process(8081)
  67. # Kill any node processes running server.js
  68. system("pkill -f 'node.*server.js'")
  69. puts "HotLoader stopped"
  70. end
  71. 1 def self.show_status
  72. puts "KotlinJsonUI HotLoader Status"
  73. puts "============================="
  74. # Check if server is running
  75. if port_in_use?(8081)
  76. puts "Status: ✅ Running"
  77. # Try to get server info
  78. begin
  79. require 'net/http'
  80. require 'uri'
  81. ip = get_local_ip
  82. uri = URI.parse("http://#{ip}:8081/")
  83. response = Net::HTTP.get_response(uri)
  84. if response.code == '200'
  85. info = JSON.parse(response.body)
  86. puts "Project: #{info['projectRoot']}"
  87. puts "Connected clients: #{info['connectedClients']}"
  88. end
  89. rescue => e
  90. puts "Server is running but couldn't get details"
  91. end
  92. else
  93. puts "Status: ❌ Not running"
  94. end
  95. # Show configuration
  96. config_file = File.join(find_project_root, 'kjui.config.json')
  97. if File.exist?(config_file)
  98. config = JSON.parse(File.read(config_file))
  99. if config['hotloader']
  100. puts "\nConfiguration:"
  101. puts "IP: #{config['hotloader']['ip']}"
  102. puts "Port: #{config['hotloader']['port']}"
  103. puts "Enabled: #{config['hotloader']['enabled']}"
  104. end
  105. end
  106. end
  107. 1 def self.show_help
  108. 5 puts <<~HELP
  109. KotlinJsonUI HotLoader Commands
  110. ===============================
  111. Usage: kjui hotload <command>
  112. Commands:
  113. start, listen - Start the hotloader server
  114. stop - Stop the hotloader server
  115. status - Show server status
  116. The hotloader enables real-time UI updates during development.
  117. It watches for changes in Layouts/ and Styles/ directories and
  118. automatically rebuilds and reloads the UI in your Android app.
  119. Example:
  120. kjui hotload start # Start development server
  121. kjui hotload stop # Stop server
  122. kjui hotload status # Check if server is running
  123. HELP
  124. end
  125. 1 def self.find_project_root(start_path = Dir.pwd)
  126. 4 current = start_path
  127. 4 while current != '/'
  128. # Check for kjui.config.json
  129. 5 if File.exist?(File.join(current, 'kjui.config.json'))
  130. 2 return current
  131. end
  132. # Check for Android project files
  133. 3 if File.exist?(File.join(current, 'build.gradle.kts')) ||
  134. File.exist?(File.join(current, 'settings.gradle.kts'))
  135. 2 return current
  136. end
  137. 1 current = File.dirname(current)
  138. end
  139. Dir.pwd
  140. end
  141. 1 def self.get_local_ip
  142. 1 require 'socket'
  143. # Try common interface names
  144. 1 interfaces = ['wlan0', 'wlp2s0', 'en0', 'en1', 'eth0']
  145. 1 interfaces.each do |interface|
  146. 3 Socket.getifaddrs.each do |ifaddr|
  147. 95 if ifaddr.name == interface && ifaddr.addr&.ipv4?
  148. 1 ip = ifaddr.addr.ip_address
  149. 1 return ip unless ip.start_with?('127.')
  150. end
  151. end
  152. end
  153. # Fallback
  154. Socket.ip_address_list.find { |ai| ai.ipv4? && !ai.ipv4_loopback? }&.ip_address || '127.0.0.1'
  155. rescue
  156. '127.0.0.1'
  157. end
  158. 1 def self.port_in_use?(port)
  159. 1 system("lsof -i:#{port} > /dev/null 2>&1")
  160. end
  161. 1 def self.kill_port_process(port)
  162. if port_in_use?(port)
  163. puts "Killing existing process on port #{port}..."
  164. system("lsof -ti:#{port} | xargs kill -9 2>/dev/null")
  165. sleep 1
  166. end
  167. end
  168. end
  169. end
  170. end
  171. end

lib/cli/commands/init.rb

71.03% lines covered

107 relevant lines. 76 lines covered and 31 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'optparse'
  3. 1 require 'fileutils'
  4. 1 require 'json'
  5. 1 require_relative '../../core/config_manager'
  6. 1 require_relative '../../core/project_finder'
  7. 1 module KjuiTools
  8. 1 module CLI
  9. 1 module Commands
  10. 1 class Init
  11. 1 def run(args)
  12. 10 options = parse_options(args)
  13. # Check if MODE file exists (set by installer)
  14. 9 installer_mode = nil
  15. 9 mode_file = File.join(File.dirname(__FILE__), '../../../../MODE')
  16. 9 if File.exist?(mode_file)
  17. installer_mode = File.read(mode_file).strip
  18. end
  19. # Detect or use specified mode
  20. 9 mode = options[:mode] || installer_mode || Core::ConfigManager.detect_mode
  21. 9 puts "Initializing KotlinJsonUI project in #{mode} mode..."
  22. # Create config file
  23. 9 create_config_file(mode)
  24. # Create directory structure based on mode
  25. 9 case mode
  26. when 'xml'
  27. 3 create_xml_structure
  28. when 'compose'
  29. 6 create_compose_structure
  30. when 'all'
  31. create_xml_structure
  32. create_compose_structure
  33. end
  34. 9 puts "Initialization complete!"
  35. 9 puts
  36. 9 if mode == 'compose'
  37. 6 puts "Compose mode initialized. Use Compose-specific commands for your project."
  38. else
  39. 3 puts "Next steps:"
  40. 3 puts " 1. Run 'kjui setup' to install dependencies and base files"
  41. 3 puts " 2. Run 'kjui g view HomeView' to generate your first view"
  42. end
  43. end
  44. 1 private
  45. 1 def parse_options(args)
  46. 10 options = {}
  47. 10 OptionParser.new do |opts|
  48. 10 opts.banner = "Usage: kjui init [options]"
  49. 10 opts.on('--mode MODE', ['all', 'xml', 'compose'],
  50. 'Initialize mode (all, xml, compose)') do |mode|
  51. 8 options[:mode] = mode
  52. end
  53. 10 opts.on('-h', '--help', 'Show this help message') do
  54. 1 puts opts
  55. 1 exit
  56. end
  57. end.parse!(args)
  58. 9 options
  59. end
  60. 1 def create_config_file(mode)
  61. 9 config_file = 'kjui.config.json'
  62. 9 if File.exist?(config_file)
  63. 1 puts "Config file already exists: #{config_file}"
  64. # Check if source_directory needs to be updated
  65. 1 existing_config = JSON.parse(File.read(config_file))
  66. 1 if existing_config['source_directory'].to_s.empty?
  67. Core::ProjectFinder.setup_paths
  68. # Auto-detect source directory without checking config
  69. project_dir = Core::ProjectFinder.project_dir
  70. # If project_dir is nil, fallback to finding gradle files
  71. if project_dir.nil?
  72. gradle_file = Dir.glob('build.gradle*').first || Dir.glob('../build.gradle*').first
  73. project_dir = gradle_file ? File.dirname(File.expand_path(gradle_file)) : Dir.pwd
  74. end
  75. common_names = ['app/src/main', 'src/main', 'src', File.basename(project_dir)]
  76. source_dir = nil
  77. common_names.each do |name|
  78. path = File.join(project_dir, name)
  79. if Dir.exist?(path)
  80. source_dir = name
  81. break
  82. end
  83. end
  84. if source_dir && !source_dir.empty?
  85. existing_config['source_directory'] = source_dir
  86. File.write(config_file, JSON.pretty_generate(existing_config))
  87. puts "Updated source_directory to: #{source_dir}"
  88. end
  89. end
  90. 1 return
  91. end
  92. # Find project info
  93. 8 Core::ProjectFinder.setup_paths
  94. # Get project name from settings.gradle or current directory
  95. 8 project_name = get_project_name_from_gradle || File.basename(Dir.pwd)
  96. # Create base config based on mode
  97. 8 if mode == 'compose'
  98. # Detect package name
  99. 5 package_name = Core::ProjectFinder.package_name
  100. # Compose-specific config with appropriate defaults
  101. # Detect if we're in a module or main app
  102. 5 source_dir = if Dir.exist?('src/main')
  103. 'src/main'
  104. 5 elsif Dir.exist?('app/src/main')
  105. 'app/src/main'
  106. else
  107. 5 Core::ProjectFinder.find_source_directory || 'src/main'
  108. end
  109. config = {
  110. 5 'mode' => mode,
  111. 'project_name' => project_name,
  112. 'source_directory' => source_dir,
  113. 'layouts_directory' => 'assets/Layouts',
  114. 'styles_directory' => 'assets/Styles',
  115. 'data_directory' => "kotlin/#{package_name.gsub('.', '/')}/data",
  116. 'viewmodel_directory' => "kotlin/#{package_name.gsub('.', '/')}/viewmodels",
  117. 'view_directory' => "kotlin/#{package_name.gsub('.', '/')}/views",
  118. 'extension_directory' => "kotlin/#{package_name.gsub('.', '/')}/extensions",
  119. 'adapter_directory' => "kotlin/#{package_name.gsub('.', '/')}/adapters",
  120. 'resource_manager_directory' => "kotlin/#{package_name.gsub('.', '/')}/generated",
  121. 'package_name' => package_name,
  122. 'string_files' => [
  123. 'res/values/strings.xml',
  124. 'res/values-ja/strings.xml'
  125. ],
  126. 'use_network' => true, # Compose mode can use network for API calls
  127. 'hotloader' => {
  128. 'ip' => '127.0.0.1',
  129. 'port' => 8081,
  130. 'watch_directories' => ['assets/Layouts', 'assets/Styles']
  131. }
  132. }
  133. else
  134. # XML mode or all mode config
  135. config = {
  136. 3 'mode' => mode,
  137. 'project_name' => project_name,
  138. 'project_file_name' => project_name,
  139. 'source_directory' => Core::ProjectFinder.find_source_directory || 'app/src/main',
  140. 'layouts_directory' => 'res/raw/layouts',
  141. 'styles_directory' => 'res/raw/styles',
  142. 'view_directory' => 'java/com/example/app/ui',
  143. 'data_directory' => 'java/com/example/app/data',
  144. 'viewmodel_directory' => 'java/com/example/app/viewmodel',
  145. 'bindings_directory' => 'java/com/example/app/bindings',
  146. 'extension_directory' => 'java/com/example/app/extensions',
  147. 'adapter_directory' => 'java/com/example/app/adapters',
  148. 'resource_manager_directory' => 'java/com/example/app/generated',
  149. 'string_files' => [
  150. 'res/values/strings.xml',
  151. 'res/values-ja/strings.xml'
  152. ],
  153. 'use_network' => true,
  154. 'hotloader' => {
  155. 'ip' => '127.0.0.1',
  156. 'port' => 8081,
  157. 'watch_directories' => ['res/raw/layouts', 'res/raw/styles']
  158. }
  159. }
  160. # Add Compose config if mode is 'all'
  161. 3 if mode == 'all'
  162. config['compose'] = {
  163. 'output_directory' => 'java/com/example/app/generated'
  164. }
  165. end
  166. end
  167. 8 File.write(config_file, JSON.pretty_generate(config))
  168. 8 puts "Created config file: #{config_file}"
  169. end
  170. 1 def create_xml_structure
  171. 3 directories = %w[
  172. res/raw/layouts
  173. res/raw/styles
  174. java/com/example/app/ui
  175. java/com/example/app/ui/activities
  176. java/com/example/app/ui/fragments
  177. java/com/example/app/data
  178. java/com/example/app/viewmodel
  179. java/com/example/app/bindings
  180. java/com/example/app/core
  181. java/com/example/app/core/base
  182. ]
  183. 3 create_directories(directories)
  184. end
  185. 1 def create_compose_structure
  186. # Read config to get directory names
  187. 6 config = Core::ConfigManager.load_config
  188. 6 source_dir = config['source_directory'] || 'app/src/main'
  189. directories = [
  190. 6 File.join(source_dir, config['layouts_directory'] || 'assets/Layouts'),
  191. File.join(source_dir, config['styles_directory'] || 'assets/Styles')
  192. ]
  193. # Add data directory if configured
  194. 6 if config['data_directory']
  195. directories << File.join(source_dir, config['data_directory'])
  196. end
  197. # Add viewmodel directory if configured
  198. 6 if config['viewmodel_directory']
  199. directories << File.join(source_dir, config['viewmodel_directory'])
  200. end
  201. # Add view directory if configured
  202. 6 if config['view_directory']
  203. directories << File.join(source_dir, config['view_directory'])
  204. end
  205. 6 create_directories(directories)
  206. end
  207. 1 def create_directories(directories)
  208. 9 directories.each do |dir|
  209. 42 unless Dir.exist?(dir)
  210. 42 FileUtils.mkdir_p(dir)
  211. 42 puts "Created directory: #{dir}"
  212. end
  213. end
  214. end
  215. 1 def get_project_name_from_gradle
  216. # Try settings.gradle.kts first
  217. 8 if File.exist?('settings.gradle.kts')
  218. content = File.read('settings.gradle.kts')
  219. if content =~ /rootProject\.name\s*=\s*["']([^"']+)["']/
  220. return $1
  221. end
  222. end
  223. # Try settings.gradle
  224. 8 if File.exist?('settings.gradle')
  225. content = File.read('settings.gradle')
  226. if content =~ /rootProject\.name\s*=\s*["']([^"']+)["']/
  227. return $1
  228. end
  229. end
  230. nil
  231. end
  232. end
  233. end
  234. end
  235. end

lib/cli/commands/setup.rb

73.21% lines covered

56 relevant lines. 41 lines covered and 15 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'optparse'
  3. 1 require 'fileutils'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module CLI
  8. 1 module Commands
  9. 1 class Setup
  10. 1 def run(args)
  11. 6 options = parse_options(args)
  12. # Check and install dependencies first
  13. 6 ensure_dependencies_installed
  14. # Setup project paths
  15. 6 Core::ProjectFinder.setup_paths
  16. # Load config to determine mode
  17. 6 config = Core::ConfigManager.load_config
  18. 6 mode = config['mode'] || 'compose'
  19. 6 puts "Setting up KotlinJsonUI project in #{mode} mode..."
  20. # Setup based on mode
  21. 6 case mode
  22. when 'compose'
  23. 3 setup_compose_project
  24. when 'xml'
  25. 2 setup_xml_project
  26. when 'all'
  27. 1 setup_xml_project
  28. 1 setup_compose_project
  29. end
  30. 6 puts "\nSetup complete!"
  31. 6 if mode == 'compose'
  32. 3 puts "Next steps:"
  33. 3 puts " 1. Create your layouts in the assets/Layouts directory"
  34. 3 puts " 2. Run 'kjui convert' to generate Compose code"
  35. 3 puts " 3. Build your project with Gradle"
  36. else
  37. 3 puts "Next steps:"
  38. 3 puts " 1. Run 'kjui g view HomeView' to generate your first view"
  39. 3 puts " 2. Build your project with Gradle"
  40. end
  41. end
  42. 1 private
  43. 1 def ensure_dependencies_installed
  44. # Check if Gemfile.lock exists
  45. kjui_tools_dir = File.expand_path('../../../..', __FILE__)
  46. gemfile_lock = File.join(kjui_tools_dir, 'Gemfile.lock')
  47. unless File.exist?(gemfile_lock)
  48. puts "Installing kjui_tools dependencies..."
  49. Dir.chdir(kjui_tools_dir) do
  50. success = system('bundle install')
  51. unless success
  52. puts "Warning: Failed to install some dependencies"
  53. puts "You may need to install them manually with: cd kjui_tools && bundle install"
  54. end
  55. end
  56. end
  57. end
  58. 1 def parse_options(args)
  59. 9 options = {}
  60. 9 OptionParser.new do |opts|
  61. 9 opts.banner = "Usage: kjui setup [options]"
  62. 9 opts.on('-h', '--help', 'Show this help message') do
  63. 2 puts opts
  64. 2 exit
  65. end
  66. end.parse!(args)
  67. 7 options
  68. end
  69. 1 def setup_compose_project
  70. require_relative '../../compose/setup/compose_setup'
  71. # Use the Compose-specific setup
  72. setup = ::KjuiTools::Compose::Setup::ComposeSetup.new(Core::ProjectFinder.project_file_path)
  73. setup.run_full_setup
  74. end
  75. 1 def setup_xml_project
  76. require_relative '../../xml/setup/xml_setup'
  77. # Use the XML-specific setup
  78. setup = ::KjuiTools::Xml::Setup::XmlSetup.new(Core::ProjectFinder.project_file_path)
  79. setup.run_full_setup
  80. end
  81. end
  82. end
  83. end
  84. end

lib/cli/main.rb

89.29% lines covered

28 relevant lines. 25 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative 'version'
  3. 1 require_relative 'commands/init'
  4. 1 require_relative 'commands/setup'
  5. 1 require_relative 'commands/build'
  6. 1 require_relative 'commands/generate'
  7. 1 require_relative 'commands/hotload'
  8. 1 module KjuiTools
  9. 1 module CLI
  10. 1 class Main
  11. 1 def self.run(args)
  12. 19 command = args.shift
  13. 19 case command
  14. when 'init'
  15. 1 Commands::Init.new.run(args)
  16. when 'setup'
  17. 1 Commands::Setup.new.run(args)
  18. when 'generate', 'g'
  19. 2 Commands::Generate.new.run(args)
  20. when 'build', 'b'
  21. 2 Commands::Build.new.run(args)
  22. when 'hotload', 'hot'
  23. 2 Commands::Hotload.run(args)
  24. when 'watch', 'w'
  25. 2 puts "Watch command not yet implemented"
  26. when 'version', 'v', '--version', '-v'
  27. 4 puts "KotlinJsonUI Tools version #{VERSION}"
  28. when 'help', '--help', '-h', nil
  29. 4 show_help
  30. else
  31. 1 puts "Unknown command: #{command}"
  32. 1 show_help
  33. 1 exit 1
  34. end
  35. rescue StandardError => e
  36. puts "Error: #{e.message}"
  37. puts e.backtrace if ENV['DEBUG']
  38. exit 1
  39. end
  40. 1 def self.show_help
  41. 11 puts <<~HELP
  42. KotlinJsonUI Tools - JSON-based UI framework for Android
  43. Usage: kjui <command> [options]
  44. Commands:
  45. init Initialize a new KotlinJsonUI project
  46. generate, g Generate views and components
  47. setup Set up project dependencies
  48. build, b Build the project
  49. hotload, hot Start/stop hotload server for real-time updates
  50. watch, w Watch for file changes
  51. version, v Show version information
  52. help Show this help message
  53. Examples:
  54. kjui init --mode compose Initialize a Jetpack Compose project
  55. kjui init --mode xml Initialize an XML-based project
  56. kjui g view HomeView Generate a new view
  57. For more information on a specific command:
  58. kjui <command> --help
  59. HELP
  60. end
  61. end
  62. end
  63. end

lib/cli/version.rb

100.0% lines covered

3 relevant lines. 3 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module KjuiTools
  3. 1 module CLI
  4. 1 VERSION = '1.0.0'
  5. end
  6. end

lib/compose/build_cache_manager.rb

74.73% lines covered

91 relevant lines. 68 lines covered and 23 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require 'pathname'
  5. 1 require 'digest'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 class BuildCacheManager
  9. 1 def initialize(source_path)
  10. 25 @source_path = source_path
  11. 25 @cache_dir = File.join(source_path, '.kjui_cache')
  12. 25 @last_updated_file = File.join(@cache_dir, 'last_updated.json')
  13. 25 @including_files_cache = File.join(@cache_dir, 'including_files.json')
  14. 25 @style_dependencies_cache = File.join(@cache_dir, 'style_dependencies.json')
  15. # Create cache directory if it doesn't exist
  16. 25 FileUtils.mkdir_p(@cache_dir) unless File.exist?(@cache_dir)
  17. end
  18. 1 def load_last_updated
  19. 10 return {} unless File.exist?(@last_updated_file)
  20. 2 JSON.parse(File.read(@last_updated_file))
  21. rescue JSON::ParserError
  22. 1 {}
  23. end
  24. 1 def load_last_including_files
  25. 9 return {} unless File.exist?(@including_files_cache)
  26. 1 JSON.parse(File.read(@including_files_cache))
  27. rescue JSON::ParserError
  28. {}
  29. end
  30. 1 def load_style_dependencies
  31. 8 return {} unless File.exist?(@style_dependencies_cache)
  32. JSON.parse(File.read(@style_dependencies_cache))
  33. rescue JSON::ParserError
  34. {}
  35. end
  36. 1 def needs_update?(json_file, last_updated, layouts_dir, last_including_files, style_dependencies)
  37. 5 file_name = File.basename(json_file, '.json')
  38. # Check if file exists in last_updated
  39. 5 return true unless last_updated[file_name]
  40. # Check if file has been modified
  41. 2 file_mtime = File.mtime(json_file).to_i
  42. 2 return true if file_mtime > last_updated[file_name]['mtime'].to_i
  43. # Check if any included files have been modified
  44. 1 if last_including_files[file_name]
  45. last_including_files[file_name].each do |included_file|
  46. included_path = File.join(layouts_dir, "#{included_file}.json")
  47. if File.exist?(included_path)
  48. included_mtime = File.mtime(included_path).to_i
  49. return true if included_mtime > last_updated[file_name]['mtime'].to_i
  50. end
  51. end
  52. end
  53. # Check if any style dependencies have been modified
  54. 1 if style_dependencies[file_name]
  55. styles_dir = File.join(@source_path, 'assets', 'Styles')
  56. style_dependencies[file_name].each do |style_file|
  57. style_path = File.join(styles_dir, "#{style_file}.json")
  58. if File.exist?(style_path)
  59. style_mtime = File.mtime(style_path).to_i
  60. return true if style_mtime > last_updated[file_name]['mtime'].to_i
  61. end
  62. end
  63. end
  64. # Check if any file that includes this file has been modified
  65. 1 last_including_files.each do |parent_file, includes|
  66. if includes && includes.include?(file_name)
  67. parent_path = File.join(layouts_dir, "#{parent_file}.json")
  68. if File.exist?(parent_path)
  69. parent_mtime = File.mtime(parent_path).to_i
  70. return true if parent_mtime > last_updated[file_name]['mtime'].to_i
  71. end
  72. end
  73. end
  74. 1 false
  75. end
  76. 1 def extract_includes(json_data, includes = Set.new)
  77. 14 if json_data.is_a?(Hash)
  78. # Check for include
  79. 13 if json_data['include']
  80. 6 includes.add(json_data['include'])
  81. end
  82. # Process children
  83. 13 if json_data['child']
  84. 5 if json_data['child'].is_a?(Array)
  85. 3 json_data['child'].each do |child|
  86. 4 extract_includes(child, includes)
  87. end
  88. else
  89. 2 extract_includes(json_data['child'], includes)
  90. end
  91. end
  92. 1 elsif json_data.is_a?(Array)
  93. 1 json_data.each do |item|
  94. 2 extract_includes(item, includes)
  95. end
  96. end
  97. 14 includes.to_a
  98. end
  99. 1 def extract_styles(json_data, styles = Set.new)
  100. 8 if json_data.is_a?(Hash)
  101. # Check for style attribute
  102. 8 if json_data['style']
  103. 3 styles.add(json_data['style'])
  104. end
  105. # Process children
  106. 8 if json_data['child']
  107. 3 if json_data['child'].is_a?(Array)
  108. 3 json_data['child'].each do |child|
  109. 4 extract_styles(child, styles)
  110. end
  111. else
  112. extract_styles(json_data['child'], styles)
  113. end
  114. end
  115. elsif json_data.is_a?(Array)
  116. json_data.each do |item|
  117. extract_styles(item, styles)
  118. end
  119. end
  120. 8 styles.to_a
  121. end
  122. 1 def save_cache(including_files, style_dependencies)
  123. # Update last_updated with current timestamps
  124. 3 last_updated = {}
  125. # Get all processed files
  126. 3 all_files = (including_files.keys + style_dependencies.keys).uniq
  127. 3 all_files.each do |file_name|
  128. 1 layouts_dir = File.join(@source_path, 'assets', 'Layouts')
  129. 1 json_file = File.join(layouts_dir, "#{file_name}.json")
  130. 1 if File.exist?(json_file)
  131. 1 last_updated[file_name] = {
  132. 'mtime' => File.mtime(json_file).to_i,
  133. 'hash' => Digest::MD5.hexdigest(File.read(json_file))
  134. }
  135. end
  136. end
  137. # Save all cache files
  138. 3 File.write(@last_updated_file, JSON.pretty_generate(last_updated))
  139. 3 File.write(@including_files_cache, JSON.pretty_generate(including_files))
  140. 3 File.write(@style_dependencies_cache, JSON.pretty_generate(style_dependencies))
  141. end
  142. 1 def clean_cache
  143. 2 FileUtils.rm_rf(@cache_dir)
  144. 2 FileUtils.mkdir_p(@cache_dir)
  145. end
  146. end
  147. end
  148. end

lib/compose/components/blurview_component.rb

76.47% lines covered

34 relevant lines. 26 lines covered and 8 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class BlurviewComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # BlurView in Compose requires a special modifier or library
  10. # For now, we'll create a semi-transparent overlay as a fallback
  11. 3 code = indent("Box(", depth)
  12. # Build modifiers
  13. 3 modifiers = []
  14. 3 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  15. 3 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  16. 3 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  17. # Add blur effect
  18. 3 blur_radius = json_data['blurRadius'] || 10
  19. # Try to use real blur modifier (available in Compose 1.3+)
  20. 3 required_imports&.add(:blur)
  21. 3 modifiers << ".blur(#{blur_radius}.dp)"
  22. # Background color
  23. 3 if json_data['backgroundColor']
  24. bg_color = json_data['backgroundColor']
  25. opacity = json_data['opacity'] || 0.8
  26. modifiers << ".background(Helpers::ResourceResolver.process_color('#{bg_color}', required_imports).copy(alpha = #{opacity}f))"
  27. end
  28. # Add corner radius if specified
  29. 3 if json_data['cornerRadius']
  30. required_imports&.add(:shape)
  31. modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
  32. end
  33. 3 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  34. 3 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  35. 3 code += "\n" + indent(") {", depth)
  36. # Process children
  37. 3 children = json_data['child'] || []
  38. 3 children = [children] unless children.is_a?(Array)
  39. # Return structure for parent to process children
  40. 3 { code: code, children: children, closing: "\n" + indent("}", depth) }
  41. end
  42. 1 private
  43. 1 def self.indent(text, level)
  44. 9 return text if level == 0
  45. spaces = ' ' * level
  46. text.split("\n").map { |line|
  47. line.empty? ? line : spaces + line
  48. }.join("\n")
  49. end
  50. end
  51. end
  52. end
  53. end

lib/compose/components/button_component.rb

98.99% lines covered

99 relevant lines. 98 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class ButtonComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Button uses 'text' attribute per SwiftJsonUI spec
  10. 26 text = Helpers::ResourceResolver.process_text(json_data['text'] || 'Button', required_imports)
  11. 26 onclick = json_data['onclick']
  12. 26 code = indent("Button(", depth)
  13. 26 if onclick
  14. 1 code += "\n" + indent("onClick = { viewModel.#{onclick}() }", depth + 1)
  15. else
  16. 25 code += "\n" + indent("onClick = { }", depth + 1)
  17. end
  18. # Build modifiers (only margins and size, not padding)
  19. 26 modifiers = []
  20. 26 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  21. 26 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  22. # Format modifiers only if there are modifiers
  23. 26 if modifiers.any?
  24. 3 code += ","
  25. 3 code += Helpers::ModifierBuilder.format(modifiers, depth)
  26. end
  27. # Add shape with cornerRadius if specified
  28. 26 if json_data['cornerRadius']
  29. 1 required_imports&.add(:shape)
  30. 1 code += ",\n" + indent("shape = RoundedCornerShape(#{json_data['cornerRadius']}.dp)", depth + 1)
  31. end
  32. # Add contentPadding for internal padding
  33. # Support both 'padding' (number), 'paddings' (array), and individual padding attributes
  34. 26 padding_data = json_data['paddings'] || json_data['padding']
  35. 26 if padding_data || json_data['paddingTop'] || json_data['paddingBottom'] ||
  36. json_data['paddingLeft'] || json_data['paddingRight'] || json_data['paddingStart'] ||
  37. json_data['paddingEnd'] || json_data['paddingHorizontal'] || json_data['paddingVertical']
  38. 7 required_imports&.add(:button_padding)
  39. 7 padding_values = []
  40. 7 if padding_data
  41. # Handle paddings array or padding number
  42. 5 if padding_data.is_a?(Array)
  43. 4 case padding_data.length
  44. when 1
  45. # One value: all sides
  46. 1 padding_values << "#{padding_data[0]}.dp"
  47. when 2
  48. # Two values: [vertical, horizontal]
  49. 1 padding_values << "vertical = #{padding_data[0]}.dp"
  50. 1 padding_values << "horizontal = #{padding_data[1]}.dp"
  51. when 3
  52. # Three values: [top, horizontal, bottom]
  53. 1 padding_values << "top = #{padding_data[0]}.dp"
  54. 1 padding_values << "horizontal = #{padding_data[1]}.dp"
  55. 1 padding_values << "bottom = #{padding_data[2]}.dp"
  56. when 4
  57. # Four values: [top, right, bottom, left]
  58. 1 padding_values << "top = #{padding_data[0]}.dp"
  59. 1 padding_values << "end = #{padding_data[1]}.dp"
  60. 1 padding_values << "bottom = #{padding_data[2]}.dp"
  61. 1 padding_values << "start = #{padding_data[3]}.dp"
  62. end
  63. else
  64. # Single number: all sides
  65. 1 padding_values << "#{padding_data}.dp"
  66. end
  67. else
  68. # Handle individual padding attributes
  69. 2 top_padding = json_data['paddingTop'] || json_data['paddingVertical'] || 0
  70. 2 bottom_padding = json_data['paddingBottom'] || json_data['paddingVertical'] || 0
  71. 2 start_padding = json_data['paddingStart'] || json_data['paddingLeft'] || json_data['paddingHorizontal'] || 0
  72. 2 end_padding = json_data['paddingEnd'] || json_data['paddingRight'] || json_data['paddingHorizontal'] || 0
  73. 2 if top_padding == bottom_padding && start_padding == end_padding && top_padding == start_padding
  74. # All same, use single value
  75. padding_values << "#{top_padding}.dp" if top_padding > 0
  76. 2 elsif top_padding == bottom_padding && start_padding == end_padding
  77. # Different horizontal and vertical
  78. 1 padding_values << "horizontal = #{start_padding}.dp" if start_padding > 0
  79. 1 padding_values << "vertical = #{top_padding}.dp" if top_padding > 0
  80. else
  81. # All different, need to specify each
  82. 1 padding_values << "start = #{start_padding}.dp" if start_padding > 0
  83. 1 padding_values << "top = #{top_padding}.dp" if top_padding > 0
  84. 1 padding_values << "end = #{end_padding}.dp" if end_padding > 0
  85. 1 padding_values << "bottom = #{bottom_padding}.dp" if bottom_padding > 0
  86. end
  87. end
  88. 7 if padding_values.any?
  89. 7 code += ",\n" + indent("contentPadding = PaddingValues(#{padding_values.join(', ')})", depth + 1)
  90. end
  91. end
  92. # Button colors including normal, disabled, and pressed states
  93. 26 if json_data['background'] || json_data['disabledBackground'] || json_data['disabledFontColor'] || json_data['hilightColor']
  94. 4 required_imports&.add(:button_colors)
  95. 4 colors_code = "colors = ButtonDefaults.buttonColors("
  96. 4 color_params = []
  97. 4 if json_data['background']
  98. 1 background_color = Helpers::ResourceResolver.process_color(json_data['background'], required_imports)
  99. 1 color_params << "containerColor = #{background_color}"
  100. end
  101. 4 if json_data['disabledBackground']
  102. 1 disabled_bg_color = Helpers::ResourceResolver.process_color(json_data['disabledBackground'], required_imports)
  103. 1 color_params << "disabledContainerColor = #{disabled_bg_color}"
  104. end
  105. 4 if json_data['disabledFontColor']
  106. 1 disabled_font_color = Helpers::ResourceResolver.process_color(json_data['disabledFontColor'], required_imports)
  107. 1 color_params << "disabledContentColor = #{disabled_font_color}"
  108. end
  109. # Note: hilightColor (pressed state) isn't directly supported in Material3 ButtonDefaults
  110. # We'd need a custom button implementation or InteractionSource for true pressed state
  111. 4 if json_data['hilightColor']
  112. 1 color_params << "// hilightColor: #{json_data['hilightColor']} - Use InteractionSource for pressed state"
  113. end
  114. 4 if color_params.any?
  115. 8 colors_code += "\n" + color_params.map { |param| indent(param, depth + 2) }.join(",\n")
  116. 4 colors_code += "\n" + indent(")", depth + 1)
  117. 4 code += ",\n" + indent(colors_code, depth + 1)
  118. end
  119. end
  120. # Handle enabled attribute
  121. 26 if json_data.key?('enabled')
  122. 3 if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
  123. # Data binding for enabled
  124. 1 variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
  125. 1 code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
  126. else
  127. 2 code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
  128. end
  129. end
  130. 26 code += "\n" + indent(") {", depth)
  131. 26 code += "\n" + indent("Text(#{text})", depth + 1)
  132. # Apply text attributes if specified
  133. 26 if json_data['fontSize'] || json_data['fontColor']
  134. 2 text_code = "\n" + indent("Text(", depth + 1)
  135. 2 text_code += "\n" + indent("text = #{text},", depth + 2)
  136. 2 if json_data['fontSize']
  137. 1 text_code += "\n" + indent("fontSize = #{json_data['fontSize']}.sp,", depth + 2)
  138. end
  139. 2 if json_data['fontColor']
  140. 1 color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  141. 1 text_code += "\n" + indent("color = #{color_value},", depth + 2) if color_value
  142. end
  143. 2 text_code += "\n" + indent(")", depth + 1)
  144. 2 code = code.sub(/Text\(#{Regexp.escape(text)}\)/, text_code.strip)
  145. end
  146. 26 code += "\n" + indent("}", depth)
  147. 26 code
  148. end
  149. 1 private
  150. 1 def self.indent(text, level)
  151. 164 return text if level == 0
  152. 85 spaces = ' ' * level
  153. 85 text.split("\n").map { |line|
  154. 93 line.empty? ? line : spaces + line
  155. }.join("\n")
  156. end
  157. end
  158. end
  159. end
  160. end

lib/compose/components/checkbox_component.rb

56.76% lines covered

74 relevant lines. 42 lines covered and 32 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class CheckboxComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Check uses 'bind' for two-way binding
  10. 7 checked = if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  11. variable = $1
  12. "data.#{variable}"
  13. else
  14. 7 'false'
  15. end
  16. 7 has_label = json_data['label'] || json_data['text']
  17. 7 if has_label
  18. # Checkbox with label
  19. code = indent("Row(", depth)
  20. code += "\n" + indent("verticalAlignment = Alignment.CenterVertically,", depth + 1)
  21. # Build modifiers for Row
  22. modifiers = []
  23. modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  24. modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  25. code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  26. code += "\n" + indent(") {", depth)
  27. # Checkbox
  28. code += "\n" + indent("Checkbox(", depth + 1)
  29. code += "\n" + indent("checked = #{checked},", depth + 2)
  30. # onCheckedChange handler
  31. if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  32. variable = $1
  33. code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{variable}\" to newValue)) }", depth + 2)
  34. elsif json_data['onValueChange']
  35. code += "\n" + indent("onCheckedChange = { viewModel.#{json_data['onValueChange']}(it) }", depth + 2)
  36. else
  37. code += "\n" + indent("onCheckedChange = { }", depth + 2)
  38. end
  39. code += "\n" + indent(")", depth + 1)
  40. # Label text
  41. label_text = json_data['label'] || json_data['text']
  42. code += "\n" + indent("Spacer(modifier = Modifier.width(8.dp))", depth + 1)
  43. code += "\n" + indent("Text(\"#{label_text}\")", depth + 1)
  44. code += "\n" + indent("}", depth)
  45. else
  46. # Checkbox without label
  47. 7 code = indent("Checkbox(", depth)
  48. 7 code += "\n" + indent("checked = #{checked},", depth + 1)
  49. # onCheckedChange handler
  50. 7 if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  51. variable = $1
  52. code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{variable}\" to newValue)) },", depth + 1)
  53. 7 elsif json_data['onValueChange']
  54. code += "\n" + indent("onCheckedChange = { viewModel.#{json_data['onValueChange']}(it) },", depth + 1)
  55. else
  56. 7 code += "\n" + indent("onCheckedChange = { },", depth + 1)
  57. end
  58. # Build modifiers
  59. 7 modifiers = []
  60. 7 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  61. 7 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  62. 7 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  63. # Add weight modifier if in Row or Column
  64. 7 if parent_type == 'Row' || parent_type == 'Column'
  65. modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  66. end
  67. 7 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  68. # Checkbox colors
  69. 7 if json_data['checkColor'] || json_data['uncheckedColor']
  70. 1 required_imports&.add(:checkbox_colors)
  71. 1 colors_params = []
  72. 1 if json_data['checkColor']
  73. 1 checked_color = Helpers::ResourceResolver.process_color(json_data['checkColor'], required_imports)
  74. 1 colors_params << "checkedColor = #{checked_color}"
  75. end
  76. 1 if json_data['uncheckedColor']
  77. unchecked_color = Helpers::ResourceResolver.process_color(json_data['uncheckedColor'], required_imports)
  78. colors_params << "uncheckedColor = #{unchecked_color}"
  79. end
  80. 1 if colors_params.any?
  81. 1 code += ",\n" + indent("colors = CheckboxDefaults.colors(", depth + 1)
  82. 2 code += "\n" + colors_params.map { |param| indent(param, depth + 2) }.join(",\n")
  83. 1 code += "\n" + indent(")", depth + 1)
  84. end
  85. end
  86. # Handle enabled attribute
  87. 7 if json_data.key?('enabled')
  88. if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
  89. variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
  90. code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
  91. else
  92. code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
  93. end
  94. end
  95. 7 code += "\n" + indent(")", depth)
  96. end
  97. 7 code
  98. end
  99. 1 private
  100. 1 def self.indent(text, level)
  101. 31 return text if level == 0
  102. 17 spaces = ' ' * level
  103. 17 text.split("\n").map { |line|
  104. 17 line.empty? ? line : spaces + line
  105. }.join("\n")
  106. end
  107. end
  108. end
  109. end
  110. end

lib/compose/components/circleimage_component.rb

100.0% lines covered

53 relevant lines. 53 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class CircleImageComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # CircleImage can be local or network image
  10. 21 is_network = json_data['url'] || (json_data['source'] && json_data['source'].start_with?('http'))
  11. 21 if is_network
  12. 4 required_imports&.add(:async_image)
  13. 4 url = process_data_binding(json_data['url'] || json_data['source'] || json_data['src'] || '')
  14. 4 code = indent("AsyncImage(", depth)
  15. 4 code += "\n" + indent("model = #{url},", depth + 1)
  16. else
  17. # Local image
  18. 17 image_name = json_data['source'] || json_data['src'] || 'placeholder'
  19. # Remove file extension and convert to resource name
  20. 17 resource_name = image_name.gsub('.png', '').gsub('.jpg', '').gsub('-', '_').downcase
  21. 17 code = indent("Image(", depth)
  22. 17 code += "\n" + indent("painter = painterResource(id = R.drawable.#{resource_name}),", depth + 1)
  23. end
  24. 21 content_description = json_data['contentDescription'] || 'Profile Image'
  25. 21 code += "\n" + indent("contentDescription = \"#{content_description}\",", depth + 1)
  26. # Content scale - typically Crop for circular images
  27. 21 required_imports&.add(:content_scale)
  28. 21 code += "\n" + indent("contentScale = ContentScale.Crop,", depth + 1)
  29. # Build modifiers for circular shape
  30. 21 modifiers = []
  31. # Size (use 'size' attribute or default to 48dp)
  32. 21 size = json_data['size'] || 48
  33. 21 modifiers << ".size(#{size}.dp)"
  34. # Circular clip
  35. 21 required_imports&.add(:shape)
  36. 21 modifiers << ".clip(CircleShape)"
  37. # Border for circle
  38. 21 if json_data['borderWidth'] && json_data['borderColor']
  39. 1 required_imports&.add(:border)
  40. 1 modifiers << ".border(#{json_data['borderWidth']}.dp, Helpers::ResourceResolver.process_color('#{json_data['borderColor']}', required_imports), CircleShape)"
  41. end
  42. # Background (in case image doesn't load)
  43. 21 if json_data['background']
  44. 1 required_imports&.add(:background)
  45. 1 modifiers << ".background(Helpers::ResourceResolver.process_color('#{json_data['background']}', required_imports))"
  46. end
  47. 21 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  48. 21 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  49. 21 code += Helpers::ModifierBuilder.format(modifiers, depth)
  50. # Error handling for network images
  51. 21 if is_network && json_data['errorImage']
  52. 1 code += ",\n" + indent("error = painterResource(R.drawable.#{json_data['errorImage'].gsub('.png', '').gsub('.jpg', '')})", depth + 1)
  53. end
  54. 21 code += "\n" + indent(")", depth)
  55. 21 code
  56. end
  57. 1 private
  58. 1 def self.process_data_binding(text)
  59. 7 return quote(text) unless text.is_a?(String)
  60. 7 if text.match(/@\{([^}]+)\}/)
  61. 2 variable = $1
  62. 2 "data.#{variable}"
  63. else
  64. 5 quote(text)
  65. end
  66. end
  67. 1 def self.quote(text)
  68. 7 "\"#{text.gsub('"', '\\"')}\""
  69. end
  70. 1 def self.indent(text, level)
  71. 108 return text if level == 0
  72. 65 spaces = ' ' * level
  73. 65 text.split("\n").map { |line|
  74. 65 line.empty? ? line : spaces + line
  75. }.join("\n")
  76. end
  77. end
  78. end
  79. end
  80. end

lib/compose/components/collection_component.rb

99.52% lines covered

208 relevant lines. 207 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 module KjuiTools
  4. 1 module Compose
  5. 1 module Components
  6. 1 class CollectionComponent
  7. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  8. 25 required_imports&.add(:lazy_grid)
  9. 25 required_imports&.add(:grid_item_span)
  10. # Check if sections are defined
  11. 25 sections = json_data['sections'] || []
  12. 25 layout = json_data['layout'] || 'vertical'
  13. 25 is_horizontal = layout == 'horizontal'
  14. # Legacy: Extract cellClasses, headerClasses, footerClasses (string arrays)
  15. 25 cell_classes = json_data['cellClasses'] || []
  16. 25 header_classes = json_data['headerClasses'] || []
  17. 25 footer_classes = json_data['footerClasses'] || []
  18. # Use the class names directly
  19. 25 cell_class_name = cell_classes.first if cell_classes.any?
  20. 25 header_class_name = header_classes.first if header_classes.any?
  21. 25 footer_class_name = footer_classes.first if footer_classes.any?
  22. # Calculate the grid columns based on sections or default
  23. 25 default_columns = json_data['columns'] || 1
  24. 25 if sections.any?
  25. # Collect all unique column counts from sections
  26. 24 section_columns = sections.map { |s| s['columns'] || default_columns }.uniq
  27. # If sections have different column counts, use LCM
  28. 11 if section_columns.size > 1
  29. 1 columns = calculate_lcm(section_columns)
  30. else
  31. 10 columns = section_columns.first
  32. end
  33. else
  34. 14 columns = default_columns
  35. end
  36. # Determine grid type based on layout
  37. 25 direction = is_horizontal ? 'horizontal' : 'vertical'
  38. 25 if direction == 'horizontal'
  39. 2 code = indent("LazyHorizontalGrid(", depth)
  40. 2 code += "\n" + indent("rows = GridCells.Fixed(#{columns}),", depth + 1)
  41. else
  42. 23 code = indent("LazyVerticalGrid(", depth)
  43. 23 code += "\n" + indent("columns = GridCells.Fixed(#{columns}),", depth + 1)
  44. end
  45. # Content padding
  46. 25 if json_data['contentPadding']
  47. 2 padding = json_data['contentPadding']
  48. 2 if padding.is_a?(Array) && padding.length == 4
  49. 1 code += "\n" + indent("contentPadding = PaddingValues(top = #{padding[0]}.dp, end = #{padding[1]}.dp, bottom = #{padding[2]}.dp, start = #{padding[3]}.dp),", depth + 1)
  50. 1 elsif padding.is_a?(Numeric)
  51. 1 code += "\n" + indent("contentPadding = PaddingValues(#{padding}.dp),", depth + 1)
  52. end
  53. end
  54. # Item spacing
  55. 25 if json_data['itemSpacing'] || json_data['spacing']
  56. 2 spacing = json_data['itemSpacing'] || json_data['spacing'] || 10
  57. 2 required_imports&.add(:arrangement)
  58. 2 code += "\n" + indent("verticalArrangement = Arrangement.spacedBy(#{spacing}.dp),", depth + 1)
  59. 2 code += "\n" + indent("horizontalArrangement = Arrangement.spacedBy(#{spacing}.dp),", depth + 1)
  60. end
  61. # Build modifiers
  62. 25 modifiers = []
  63. 25 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  64. 25 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  65. 25 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  66. 25 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  67. 25 code += Helpers::ModifierBuilder.format(modifiers, depth)
  68. 25 code += "\n" + indent(") {", depth)
  69. # Check if sections are defined
  70. 25 if sections.any?
  71. # Generate section-based collection
  72. 11 code += generate_sections_content(json_data, sections, columns, depth, required_imports)
  73. 14 elsif cell_class_name
  74. # Check if items property is specified (e.g., "@{items}")
  75. 6 items_property = json_data['items']
  76. 6 if items_property && items_property.match(/@\{([^}]+)\}/)
  77. # Extract property name from @{propertyName}
  78. 4 property_name = $1
  79. # Items should be a Map<String, List<Any>> where key is cell class name
  80. # Get the items for this specific cell class
  81. 4 code += "\n" + indent("// Collection with data source: #{property_name}[\"#{cell_class_name}\"]", depth + 1)
  82. 4 code += "\n" + indent("val cellItems = data.#{property_name}[\"#{cell_class_name}\"] ?: emptyList()", depth + 1)
  83. 4 code += "\n" + indent("items(cellItems.size) { index ->", depth + 1)
  84. 4 code += "\n" + indent("val item = cellItems[index]", depth + 2)
  85. else
  86. # Default to empty list
  87. 2 code += "\n" + indent("// Collection with no data source", depth + 1)
  88. 2 code += "\n" + indent("items(0) { index ->", depth + 1)
  89. 2 code += "\n" + indent("// No items", depth + 2)
  90. end
  91. # Create cell view with data
  92. 6 code += "\n" + indent("when (val itemData = item) {", depth + 2)
  93. 6 code += "\n" + indent("is #{cell_class_name}Data -> {", depth + 3)
  94. 6 code += "\n" + indent("#{cell_class_name}View(", depth + 4)
  95. 6 code += "\n" + indent("data = itemData,", depth + 5)
  96. 6 code += "\n" + indent("viewModel = viewModel(),", depth + 5)
  97. 6 code += "\n" + indent("modifier = Modifier", depth + 5)
  98. # Cell-specific modifiers
  99. 6 if json_data['cellHeight']
  100. 1 code += "\n" + indent(" .height(#{json_data['cellHeight']}.dp)", depth + 5)
  101. end
  102. # For grid layouts, ensure cells expand to fill width
  103. 6 if columns > 1
  104. 1 code += "\n" + indent(" .fillMaxWidth()", depth + 5)
  105. end
  106. 6 code += "\n" + indent(")", depth + 4)
  107. 6 code += "\n" + indent("}", depth + 3)
  108. 6 code += "\n" + indent("is Map<*, *> -> {", depth + 3)
  109. 6 code += "\n" + indent("// Convert map to data class", depth + 4)
  110. 6 code += "\n" + indent("val data = #{cell_class_name}Data.fromMap(itemData as Map<String, Any>)", depth + 4)
  111. 6 code += "\n" + indent("#{cell_class_name}View(", depth + 4)
  112. 6 code += "\n" + indent("data = data,", depth + 5)
  113. 6 code += "\n" + indent("viewModel = viewModel(),", depth + 5)
  114. 6 code += "\n" + indent("modifier = Modifier", depth + 5)
  115. # Cell-specific modifiers
  116. 6 if json_data['cellHeight']
  117. 1 code += "\n" + indent(" .height(#{json_data['cellHeight']}.dp)", depth + 5)
  118. end
  119. # For grid layouts, ensure cells expand to fill width
  120. 6 if columns > 1
  121. 1 code += "\n" + indent(" .fillMaxWidth()", depth + 5)
  122. end
  123. 6 code += "\n" + indent(")", depth + 4)
  124. 6 code += "\n" + indent("}", depth + 3)
  125. 6 code += "\n" + indent("else -> {", depth + 3)
  126. 6 code += "\n" + indent("// Unsupported item type", depth + 4)
  127. 6 code += "\n" + indent("}", depth + 3)
  128. 6 code += "\n" + indent("}", depth + 2)
  129. 6 code += "\n" + indent("}", depth + 1)
  130. else
  131. # No cell class specified - show placeholder
  132. 8 code += "\n" + indent("// No cellClasses specified", depth + 1)
  133. 8 code += "\n" + indent("items(10) { index ->", depth + 1)
  134. 8 code += "\n" + indent("Card(", depth + 2)
  135. 8 code += "\n" + indent("modifier = Modifier", depth + 3)
  136. 8 code += "\n" + indent(" .padding(4.dp)", depth + 3)
  137. 8 code += "\n" + indent(" .fillMaxWidth()", depth + 3)
  138. 8 code += "\n" + indent(" .height(80.dp)", depth + 3)
  139. 8 code += "\n" + indent(") {", depth + 2)
  140. 8 code += "\n" + indent("Box(", depth + 3)
  141. 8 code += "\n" + indent("modifier = Modifier.fillMaxSize(),", depth + 4)
  142. 8 code += "\n" + indent("contentAlignment = Alignment.Center", depth + 4)
  143. 8 code += "\n" + indent(") {", depth + 3)
  144. 8 code += "\n" + indent("Text(\"Item ${index}\")", depth + 4)
  145. 8 code += "\n" + indent("}", depth + 3)
  146. 8 code += "\n" + indent("}", depth + 2)
  147. 8 code += "\n" + indent("}", depth + 1)
  148. end
  149. 25 code += "\n" + indent("}", depth)
  150. 25 code
  151. end
  152. 1 def self.generate_sections_content(json_data, sections, grid_columns, depth, required_imports)
  153. 11 code = ""
  154. 11 items_property = json_data['items']
  155. 11 default_columns = json_data['columns'] || 1
  156. # Check if we need GridItemSpan
  157. # Need it for headers/footers or when sections have different column counts
  158. 24 has_headers_or_footers = sections.any? { |s| s['header'] || s['footer'] }
  159. 24 section_columns_vary = sections.map { |s| s['columns'] || default_columns }.uniq.size > 1
  160. 23 needs_span = sections.any? { |s| s['columns'] && s['columns'] != grid_columns }
  161. 11 if has_headers_or_footers || section_columns_vary || needs_span
  162. 3 required_imports&.add(:grid_item_span)
  163. end
  164. 11 if items_property && items_property.match(/@\{([^}]+)\}/)
  165. 7 property_name = $1
  166. # Generate sections with GridItemSpan for different column counts
  167. 7 sections.each_with_index do |section, index|
  168. 7 cell_view_name = section['cell']
  169. 7 section_columns = section['columns'] || default_columns
  170. # Calculate the span for items in this section
  171. 7 item_span = grid_columns / section_columns
  172. 7 if cell_view_name
  173. # Add cell view imports
  174. 7 required_imports&.add("cell:#{cell_view_name}")
  175. # Add header import if exists
  176. 7 if section['header']
  177. 1 required_imports&.add("cell:#{section['header']}")
  178. end
  179. # Add footer import if exists
  180. 7 if section['footer']
  181. 1 required_imports&.add("cell:#{section['footer']}")
  182. end
  183. 7 code += "\n" + indent("// Section #{index + 1}: #{cell_view_name} (#{section_columns} columns)", depth + 1)
  184. 7 code += "\n" + indent("data.#{property_name}.sections.getOrNull(#{index})?.let { section ->", depth + 1)
  185. # Generate header if present
  186. 7 if section['header']
  187. 1 header_view_name = section['header']
  188. 1 code += "\n" + indent("// Section #{index + 1} Header: #{header_view_name}", depth + 2)
  189. 1 code += "\n" + indent("section.header?.let { headerData ->", depth + 2)
  190. 1 code += "\n" + indent("item(span = { GridItemSpan(maxLineSpan) }) {", depth + 3)
  191. 1 code += "\n" + indent("val data = #{header_view_name}Data.fromMap(headerData.data)", depth + 4)
  192. 1 code += "\n" + indent("#{header_view_name}View(", depth + 4)
  193. 1 code += "\n" + indent("data = data,", depth + 5)
  194. 1 code += "\n" + indent("viewModel = viewModel(),", depth + 5)
  195. 1 code += "\n" + indent("modifier = Modifier.fillMaxWidth()", depth + 5)
  196. 1 code += "\n" + indent(")", depth + 4)
  197. 1 code += "\n" + indent("}", depth + 3)
  198. 1 code += "\n" + indent("}", depth + 2)
  199. end
  200. # Generate cells
  201. 7 code += "\n" + indent("section.cells?.let { cellData ->", depth + 2)
  202. 7 if item_span > 1
  203. code += "\n" + indent("items(cellData.data.size, span = { GridItemSpan(#{item_span}) }) { cellIndex ->", depth + 3)
  204. else
  205. 7 code += "\n" + indent("items(cellData.data.size) { cellIndex ->", depth + 3)
  206. end
  207. 7 code += "\n" + indent("val item = cellData.data[cellIndex]", depth + 4)
  208. # Generate cell view instantiation
  209. 7 code += "\n" + indent("when (item) {", depth + 4)
  210. 7 code += "\n" + indent("is Map<*, *> -> {", depth + 5)
  211. 7 code += "\n" + indent("val data = #{cell_view_name}Data.fromMap(item as Map<String, Any>)", depth + 6)
  212. 7 code += "\n" + indent("#{cell_view_name}View(", depth + 6)
  213. 7 code += "\n" + indent("data = data,", depth + 7)
  214. 7 code += "\n" + indent("viewModel = viewModel(),", depth + 7)
  215. 7 code += "\n" + indent("modifier = Modifier", depth + 7)
  216. # Add modifiers
  217. 7 if json_data['cellWidth'] && json_data['layout'] == 'horizontal'
  218. 1 code += "\n" + indent(" .width(#{json_data['cellWidth']}.dp)", depth + 7)
  219. 6 elsif json_data['cellHeight']
  220. 1 code += "\n" + indent(" .height(#{json_data['cellHeight']}.dp)", depth + 7)
  221. end
  222. 7 if section_columns > 1 && json_data['layout'] != 'horizontal'
  223. 3 code += "\n" + indent(" .fillMaxWidth()", depth + 7)
  224. end
  225. 7 code += "\n" + indent(")", depth + 6)
  226. 7 code += "\n" + indent("}", depth + 5)
  227. 7 code += "\n" + indent("else -> {", depth + 5)
  228. 7 code += "\n" + indent("// Unsupported item type", depth + 6)
  229. 7 code += "\n" + indent("}", depth + 5)
  230. 7 code += "\n" + indent("}", depth + 4)
  231. 7 code += "\n" + indent("}", depth + 3)
  232. 7 code += "\n" + indent("}", depth + 2)
  233. # Generate footer if present
  234. 7 if section['footer']
  235. 1 footer_view_name = section['footer']
  236. 1 code += "\n" + indent("// Section #{index + 1} Footer: #{footer_view_name}", depth + 2)
  237. 1 code += "\n" + indent("section.footer?.let { footerData ->", depth + 2)
  238. 1 code += "\n" + indent("item(span = { GridItemSpan(maxLineSpan) }) {", depth + 3)
  239. 1 code += "\n" + indent("val data = #{footer_view_name}Data.fromMap(footerData.data)", depth + 4)
  240. 1 code += "\n" + indent("#{footer_view_name}View(", depth + 4)
  241. 1 code += "\n" + indent("data = data,", depth + 5)
  242. 1 code += "\n" + indent("viewModel = viewModel(),", depth + 5)
  243. 1 code += "\n" + indent("modifier = Modifier.fillMaxWidth()", depth + 5)
  244. 1 code += "\n" + indent(")", depth + 4)
  245. 1 code += "\n" + indent("}", depth + 3)
  246. 1 code += "\n" + indent("}", depth + 2)
  247. end
  248. 7 code += "\n" + indent("}", depth + 1)
  249. end
  250. end
  251. else
  252. 4 code += "\n" + indent("// No items binding specified", depth + 1)
  253. end
  254. 11 code
  255. end
  256. 1 private
  257. 1 def self.calculate_lcm(numbers)
  258. 11 numbers.reduce(1) { |lcm, n| lcm.lcm(n) }
  259. end
  260. 1 def self.extract_view_name(class_name)
  261. 4 return nil unless class_name
  262. # Convert cell class name to Compose view name
  263. # Remove common suffixes and add appropriate naming
  264. 3 view_name = class_name
  265. # Remove common UIKit/Android suffixes
  266. 3 view_name = view_name.sub(/CollectionViewCell$/, '')
  267. 3 view_name = view_name.sub(/Cell$/, '')
  268. 3 view_name = view_name.sub(/cell$/, '')
  269. # Convert to proper case and add View suffix if needed
  270. 3 view_name = to_pascal_case(view_name)
  271. 3 view_name += 'View' unless view_name.end_with?('View')
  272. 3 view_name
  273. end
  274. 1 def self.to_pascal_case(str)
  275. 7 return str if str.nil? || str.empty?
  276. # Handle snake_case or kebab-case to PascalCase
  277. 5 parts = str.split(/[_-]/)
  278. 5 parts.map(&:capitalize).join
  279. end
  280. 1 def self.indent(text, level)
  281. 573 return text if level == 0
  282. 497 spaces = ' ' * level
  283. 497 text.split("\n").map { |line|
  284. 499 line.empty? ? line : spaces + line
  285. }.join("\n")
  286. end
  287. end
  288. end
  289. end
  290. end

lib/compose/components/constraintlayout_component.rb

92.25% lines covered

142 relevant lines. 131 lines covered and 11 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class ConstraintLayoutComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. 6 required_imports&.add(:constraint_layout)
  10. # Check if any child has relative positioning attributes
  11. 6 children = json_data['child'] || []
  12. 6 children = [children] unless children.is_a?(Array)
  13. 11 has_constraints = children.any? { |child| has_relative_positioning?(child) }
  14. 6 if has_constraints
  15. 4 generate_constraint_layout(json_data, children, depth, required_imports)
  16. else
  17. # Fall back to regular Box/Column/Row
  18. 2 Components::ContainerComponent.generate(json_data, depth, required_imports)
  19. end
  20. end
  21. 1 private
  22. 1 def self.has_relative_positioning?(component)
  23. 19 return false unless component.is_a?(Hash)
  24. 17 relative_attrs = [
  25. 'alignTopOfView', 'alignBottomOfView', 'alignLeftOfView', 'alignRightOfView',
  26. 'alignTopView', 'alignBottomView', 'alignLeftView', 'alignRightView',
  27. 'alignCenterVerticalView', 'alignCenterHorizontalView',
  28. 'alignTop', 'alignBottom', 'alignLeft', 'alignRight',
  29. 'centerHorizontal', 'centerVertical', 'centerInParent'
  30. ]
  31. 239 relative_attrs.any? { |attr| component[attr] }
  32. end
  33. 1 def self.has_positioning_constraints?(component)
  34. 19 return false unless component.is_a?(Hash)
  35. # These are constraints that use margins in linkTo()
  36. # For alignXxxView, margins should be applied as padding modifiers
  37. # For alignTop/Bottom/Left/Right to parent, margins are applied in linkTo()
  38. 18 positioning_attrs = [
  39. 'alignTopOfView', 'alignBottomOfView', 'alignLeftOfView', 'alignRightOfView',
  40. 'alignTopView', 'alignBottomView', 'alignLeftView', 'alignRightView',
  41. 'alignCenterVerticalView', 'alignCenterHorizontalView',
  42. 'alignTop', 'alignBottom', 'alignLeft', 'alignRight'
  43. ]
  44. # centerInParent, centerHorizontal, centerVertical don't use margins in linkTo()
  45. # so they should still apply margins as padding
  46. 245 positioning_attrs.any? { |attr| component[attr] }
  47. end
  48. 1 def self.should_apply_margins_as_padding?(component)
  49. 16 return false unless component.is_a?(Hash)
  50. # Don't apply margins as padding if they're already handled in linkTo()
  51. # All positioning constraints now handle margins in linkTo()
  52. 15 return !has_positioning_constraints?(component)
  53. end
  54. 1 def self.generate_constraint_layout(json_data, children, depth, required_imports)
  55. 4 code = indent("ConstraintLayout(", depth)
  56. # Build modifiers
  57. 4 modifiers = []
  58. 4 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  59. 4 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  60. 4 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  61. 4 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  62. 4 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  63. 4 code += "\n" + indent(") {", depth)
  64. # Create constraint references
  65. 4 constraint_refs = []
  66. 4 children.each_with_index do |child, index|
  67. 4 if child.is_a?(Hash) && (child['id'] || has_relative_positioning?(child))
  68. 4 ref_name = child['id'] || "view_#{index}"
  69. 4 code += "\n" + indent("val #{ref_name} = createRef()", depth + 1)
  70. 4 constraint_refs << ref_name
  71. end
  72. end
  73. 4 code += "\n" if constraint_refs.any?
  74. # Generate children with constraints
  75. 4 children.each_with_index do |child, index|
  76. 4 if child.is_a?(Hash)
  77. 4 ref_name = child['id'] || "view_#{index}"
  78. # Generate the child component
  79. 4 child_code = generate_child_with_constraints(child, ref_name, depth + 1, required_imports)
  80. 4 code += "\n" + child_code unless child_code.empty?
  81. end
  82. end
  83. 4 code += "\n" + indent("}", depth)
  84. 4 code
  85. end
  86. 1 def self.generate_child_with_constraints(child_data, ref_name, depth, required_imports)
  87. # Get the component type
  88. 4 component_type = child_data['type'] || 'View'
  89. # Generate the component code based on type
  90. 4 component_code = case component_type
  91. when 'Text', 'Label'
  92. 3 generate_text_component(child_data, depth, required_imports)
  93. when 'Button'
  94. 1 generate_button_component(child_data, depth, required_imports)
  95. when 'Image'
  96. generate_image_component(child_data, depth, required_imports)
  97. else
  98. generate_box_component(child_data, depth, required_imports)
  99. end
  100. # Add constrainAs modifier
  101. 4 constraints = Helpers::ModifierBuilder.build_relative_positioning(child_data)
  102. # Always add constrainAs for all children in ConstraintLayout
  103. # Insert constrainAs modifier
  104. 4 constraint_content = if constraints.any?
  105. 12 constraints.map { |c| indent(c, depth + 2) }.join("\n")
  106. else
  107. "" # Empty constraint block
  108. end
  109. # Find where to insert the constrainAs modifier
  110. 4 if component_code.include?("modifier = Modifier")
  111. # Replace existing modifier with constrainAs
  112. component_code.sub!(/modifier = Modifier(.*?)(?=,\n|\n)/m) do |match|
  113. existing_modifiers = $1
  114. "modifier = Modifier.constrainAs(#{ref_name}) {\n#{constraint_content}\n" + indent("}", depth + 1) + existing_modifiers
  115. end
  116. else
  117. # Add new modifier after the opening parenthesis
  118. 4 insert_pos = component_code.index("(") + 1
  119. 4 modifier_code = "\n" + indent("modifier = Modifier.constrainAs(#{ref_name}) {", depth + 1)
  120. 4 if constraint_content.length > 0
  121. 4 modifier_code += "\n#{constraint_content}"
  122. end
  123. 4 modifier_code += "\n" + indent("}", depth + 1) + ","
  124. 4 component_code.insert(insert_pos, modifier_code)
  125. end
  126. 4 component_code
  127. end
  128. 1 def self.generate_text_component(data, depth, required_imports)
  129. 13 text = data['text'] || ''
  130. # Check for data binding
  131. 13 if text.start_with?('@{')
  132. 1 variable_name = text[2..-2]
  133. 1 escaped_text = "\"${data.#{variable_name}}\""
  134. else
  135. 12 escaped_text = quote(text)
  136. end
  137. 13 code = indent("Text(", depth)
  138. # Add modifier with constraints
  139. # In ConstraintLayout:
  140. # - If element has relative positioning constraints, margins are handled ONLY in linkTo()
  141. # - If element has no constraints (just centerInParent etc), margins are applied as padding
  142. 13 modifiers = []
  143. # Apply margins BEFORE size so they act as outer spacing
  144. # This ensures the size is the actual content size, not reduced by margins
  145. 13 if should_apply_margins_as_padding?(data)
  146. 11 modifiers.concat(Helpers::ModifierBuilder.build_margins(data))
  147. end
  148. 13 modifiers.concat(Helpers::ModifierBuilder.build_size(data))
  149. 13 modifiers.concat(Helpers::ModifierBuilder.build_background(data, required_imports))
  150. 13 modifiers.concat(Helpers::ModifierBuilder.build_padding(data))
  151. 13 if modifiers.any?
  152. code += "\n" + indent("modifier = Modifier", depth + 1)
  153. modifiers.each do |mod|
  154. code += "\n" + indent(" #{mod}", depth + 1)
  155. end
  156. code += ","
  157. end
  158. 13 code += "\n" + indent("text = #{escaped_text}", depth + 1)
  159. 13 if data['fontSize']
  160. 1 code += ",\n" + indent("fontSize = #{data['fontSize']}.sp", depth + 1)
  161. end
  162. 13 if data['fontColor'] || data['color']
  163. 2 color = data['fontColor'] || data['color']
  164. 2 color_resolved = Helpers::ResourceResolver.process_color(color, required_imports)
  165. 2 code += ",\n" + indent("color = #{color_resolved}", depth + 1)
  166. end
  167. 13 if data['font'] == 'bold' || data['fontWeight'] == 'bold'
  168. 2 required_imports&.add(:font_weight)
  169. 2 code += ",\n" + indent("fontWeight = FontWeight.Bold", depth + 1)
  170. end
  171. 13 if data['textAlign']
  172. 3 required_imports&.add(:text_align)
  173. 3 align = case data['textAlign']
  174. 1 when 'center' then 'TextAlign.Center'
  175. 1 when 'left' then 'TextAlign.Left'
  176. 1 when 'right' then 'TextAlign.Right'
  177. else 'TextAlign.Start'
  178. end
  179. 3 code += ",\n" + indent("textAlign = #{align}", depth + 1)
  180. end
  181. 13 code += "\n" + indent(")", depth)
  182. 13 code
  183. end
  184. 1 def self.generate_button_component(data, depth, required_imports)
  185. 5 text = data['text'] || 'Button'
  186. 5 onclick = data['onclick']
  187. # Properly escape text
  188. 5 escaped_text = quote(text)
  189. 5 code = indent("Button(", depth)
  190. 5 if onclick
  191. 1 code += "\n" + indent("onClick = { viewModel.#{onclick}() }", depth + 1)
  192. else
  193. 4 code += "\n" + indent("onClick = { }", depth + 1)
  194. end
  195. 5 code += "\n" + indent(") {", depth)
  196. 5 code += "\n" + indent("Text(#{escaped_text})", depth + 1)
  197. 5 code += "\n" + indent("}", depth)
  198. 5 code
  199. end
  200. 1 def self.generate_image_component(data, depth, required_imports)
  201. 4 source = data['src'] || data['source'] || 'placeholder'
  202. 4 code = indent("Image(", depth)
  203. 4 code += "\n" + indent("painter = painterResource(R.drawable.#{source.gsub('.png', '').gsub('.jpg', '')}),", depth + 1)
  204. 4 code += "\n" + indent("contentDescription = \"Image\"", depth + 1)
  205. 4 code += "\n" + indent(")", depth)
  206. 4 code
  207. end
  208. 1 def self.generate_box_component(data, depth, required_imports)
  209. 1 code = indent("Box(", depth)
  210. 1 code += "\n" + indent(") {", depth)
  211. 1 code += "\n" + indent("// Content", depth + 1)
  212. 1 code += "\n" + indent("}", depth)
  213. 1 code
  214. end
  215. 1 def self.quote(text)
  216. # Escape special characters properly
  217. 20 escaped = text.gsub('\\', '\\\\\\\\') # Escape backslashes first
  218. .gsub('"', '\\"') # Escape quotes
  219. .gsub("\n", '\\n') # Escape newlines
  220. .gsub("\r", '\\r') # Escape carriage returns
  221. .gsub("\t", '\\t') # Escape tabs
  222. 20 "\"#{escaped}\""
  223. end
  224. 1 def self.indent(text, level)
  225. 127 return text if level == 0
  226. 71 spaces = ' ' * level
  227. 71 text.split("\n").map { |line|
  228. 73 line.empty? ? line : spaces + line
  229. }.join("\n")
  230. end
  231. end
  232. end
  233. end
  234. end

lib/compose/components/container_component.rb

88.51% lines covered

87 relevant lines. 77 lines covered and 10 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative 'constraintlayout_component'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class ContainerComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. 22 container_type = json_data['type'] || 'View'
  10. 22 orientation = json_data['orientation']
  11. # Check if any child has relative positioning
  12. 22 children = json_data['child'] || []
  13. 22 children = [children] unless children.is_a?(Array)
  14. 22 if has_relative_positioning?(children)
  15. # Use ConstraintLayout for relative positioning
  16. return ConstraintLayoutComponent.generate(json_data, depth, required_imports)
  17. end
  18. # Determine layout type
  19. 22 layout = determine_layout(container_type, orientation)
  20. 22 code = indent("#{layout}(", depth)
  21. # Build modifiers (correct order for Compose)
  22. 22 modifiers = []
  23. # Add weight modifier if in Row or Column
  24. 22 if parent_type == 'Row' || parent_type == 'Column'
  25. modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  26. end
  27. # 1. Size first (total size including padding)
  28. 22 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  29. # 2. Margins (outer spacing)
  30. 22 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  31. # 3. Background (before padding so padding creates space inside)
  32. 22 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  33. # 4. Padding (inner spacing) - applied last
  34. 22 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  35. 22 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  36. # Add gravity settings
  37. 22 if json_data['gravity']
  38. 6 code += add_gravity_settings(layout, json_data['gravity'], depth)
  39. end
  40. # Add direction settings
  41. # Note: reverseLayout is only supported by LazyColumn/LazyRow, not Column/Row
  42. # For regular Row/Column, we need to manually reverse the children order
  43. 22 if json_data['direction'] && (layout == 'Column' || layout == 'Row')
  44. # Direction handling will be done by reversing children order
  45. # No reverseLayout parameter for regular Row/Column
  46. end
  47. # Add spacing for Column/Row
  48. 22 if json_data['spacing'] && (layout == 'Column' || layout == 'Row')
  49. 2 required_imports&.add(:arrangement)
  50. 2 code += ",\n" + indent("verticalArrangement = Arrangement.spacedBy(#{json_data['spacing']}.dp)", depth + 1) if layout == 'Column'
  51. 2 code += ",\n" + indent("horizontalArrangement = Arrangement.spacedBy(#{json_data['spacing']}.dp)", depth + 1) if layout == 'Row'
  52. end
  53. # Add distribution for Column/Row
  54. 22 if json_data['distribution'] && (layout == 'Column' || layout == 'Row')
  55. 3 required_imports&.add(:arrangement)
  56. 3 arrangement = case json_data['distribution']
  57. when 'fillEqually'
  58. 1 'Arrangement.SpaceEvenly'
  59. when 'fill'
  60. 1 'Arrangement.SpaceBetween'
  61. when 'equalSpacing'
  62. 1 'Arrangement.SpaceAround'
  63. when 'equalCentering'
  64. 'Arrangement.SpaceEvenly'
  65. else
  66. nil
  67. end
  68. 3 if arrangement
  69. 3 code += ",\n" + indent("verticalArrangement = #{arrangement}", depth + 1) if layout == 'Column'
  70. 3 code += ",\n" + indent("horizontalArrangement = #{arrangement}", depth + 1) if layout == 'Row'
  71. end
  72. end
  73. 22 code += "\n" + indent(") {", depth)
  74. # Process children
  75. 22 children = json_data['child'] || []
  76. 22 children = [children] unless children.is_a?(Array)
  77. # Reverse children order if direction requires it
  78. 22 if json_data['direction']
  79. 2 case json_data['direction']
  80. when 'bottomToTop'
  81. 1 children = children.reverse if layout == 'Column'
  82. when 'rightToLeft'
  83. 1 children = children.reverse if layout == 'Row'
  84. end
  85. end
  86. # Return structure for parent to process children
  87. 22 { code: code, children: children, closing: "\n" + indent("}", depth), layout_type: layout }
  88. end
  89. 1 private
  90. 1 def self.has_relative_positioning?(children)
  91. 25 relative_attrs = [
  92. 'alignTopOfView', 'alignBottomOfView', 'alignLeftOfView', 'alignRightOfView',
  93. 'alignTopView', 'alignBottomView', 'alignLeftView', 'alignRightView',
  94. 'alignCenterVerticalView', 'alignCenterHorizontalView'
  95. ]
  96. 25 children.any? do |child|
  97. 12 next false unless child.is_a?(Hash)
  98. 101 relative_attrs.any? { |attr| child[attr] }
  99. end
  100. end
  101. 1 def self.determine_layout(container_type, orientation)
  102. # SwiftJsonUI only has 'View' type, not VStack/HStack/ZStack
  103. # Layout is determined by orientation attribute:
  104. # - orientation: "vertical" → Column (VStack)
  105. # - orientation: "horizontal" → Row (HStack)
  106. # - no orientation → Box (ZStack)
  107. 26 if container_type == 'View'
  108. 23 if orientation == 'vertical'
  109. 12 'Column'
  110. 11 elsif orientation == 'horizontal'
  111. 7 'Row'
  112. else
  113. 4 'Box'
  114. end
  115. else
  116. # For other types (shouldn't happen with proper View type)
  117. 3 'Box'
  118. end
  119. end
  120. 1 def self.add_gravity_settings(layout, gravity, depth)
  121. 6 code = ""
  122. 6 if layout == 'Column'
  123. 4 case gravity
  124. when 'top'
  125. 1 code += ",\n" + indent("verticalArrangement = Arrangement.Top", depth + 1)
  126. when 'bottom'
  127. 1 code += ",\n" + indent("verticalArrangement = Arrangement.Bottom", depth + 1)
  128. when 'centerVertical'
  129. 1 code += ",\n" + indent("verticalArrangement = Arrangement.Center", depth + 1)
  130. when 'left'
  131. code += ",\n" + indent("horizontalAlignment = Alignment.Start", depth + 1)
  132. when 'right'
  133. code += ",\n" + indent("horizontalAlignment = Alignment.End", depth + 1)
  134. when 'centerHorizontal'
  135. 1 code += ",\n" + indent("horizontalAlignment = Alignment.CenterHorizontally", depth + 1)
  136. end
  137. 2 elsif layout == 'Row'
  138. 2 case gravity
  139. when 'left'
  140. 1 code += ",\n" + indent("horizontalArrangement = Arrangement.Start", depth + 1)
  141. when 'right'
  142. code += ",\n" + indent("horizontalArrangement = Arrangement.End", depth + 1)
  143. when 'centerHorizontal'
  144. code += ",\n" + indent("horizontalArrangement = Arrangement.Center", depth + 1)
  145. when 'top'
  146. code += ",\n" + indent("verticalAlignment = Alignment.Top", depth + 1)
  147. when 'bottom'
  148. code += ",\n" + indent("verticalAlignment = Alignment.Bottom", depth + 1)
  149. when 'centerVertical'
  150. 1 code += ",\n" + indent("verticalAlignment = Alignment.CenterVertically", depth + 1)
  151. end
  152. end
  153. 6 code
  154. end
  155. 1 def self.indent(text, level)
  156. 77 return text if level == 0
  157. 11 spaces = ' ' * level
  158. 11 text.split("\n").map { |line|
  159. 11 line.empty? ? line : spaces + line
  160. }.join("\n")
  161. end
  162. end
  163. end
  164. end
  165. end

lib/compose/components/gradientview_component.rb

72.73% lines covered

44 relevant lines. 32 lines covered and 12 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class GradientviewComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # GradientView maps to a Box with gradient background
  10. 4 code = indent("Box(", depth)
  11. # Build modifiers
  12. 4 modifiers = []
  13. 4 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  14. 4 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  15. 4 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  16. # Add gradient background
  17. # Support both 'colors' and 'items' for color list
  18. 4 colors = json_data['colors'] || json_data['items'] || ['#000000', '#FFFFFF']
  19. # Determine gradient direction from orientation or start/end points
  20. 4 gradient_type = if json_data['orientation']
  21. case json_data['orientation']
  22. when 'horizontal'
  23. 'horizontalGradient'
  24. when 'vertical'
  25. 'verticalGradient'
  26. when 'diagonal'
  27. 'linearGradient'
  28. else
  29. 'verticalGradient'
  30. end
  31. else
  32. 4 start_point = json_data['startPoint'] || 'top'
  33. 4 end_point = json_data['endPoint'] || 'bottom'
  34. 4 case [start_point, end_point]
  35. when ['top', 'bottom'], ['bottom', 'top']
  36. 4 'verticalGradient'
  37. when ['left', 'right'], ['leading', 'trailing'], ['right', 'left'], ['trailing', 'leading']
  38. 'horizontalGradient'
  39. else
  40. 'linearGradient'
  41. end
  42. end
  43. # Build color list - process colors at generation time, not runtime
  44. 4 color_list = colors.map { |color|
  45. 8 Helpers::ResourceResolver.process_color(color, required_imports)
  46. }.join(", ")
  47. # Add gradient modifier
  48. 4 required_imports&.add(:gradient)
  49. 4 modifiers << ".background(Brush.#{gradient_type}(listOf(#{color_list})))"
  50. # Add corner radius if specified
  51. 4 if json_data['cornerRadius']
  52. required_imports&.add(:shape)
  53. modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
  54. end
  55. 4 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  56. 4 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  57. 4 code += "\n" + indent(") {", depth)
  58. # Process children
  59. 4 children = json_data['child'] || []
  60. 4 children = [children] unless children.is_a?(Array)
  61. # Return structure for parent to process children
  62. 4 { code: code, children: children, closing: "\n" + indent("}", depth) }
  63. end
  64. 1 private
  65. 1 def self.indent(text, level)
  66. 12 return text if level == 0
  67. spaces = ' ' * level
  68. text.split("\n").map { |line|
  69. line.empty? ? line : spaces + line
  70. }.join("\n")
  71. end
  72. end
  73. end
  74. end
  75. end

lib/compose/components/image_component.rb

100.0% lines covered

39 relevant lines. 39 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 module KjuiTools
  4. 1 module Compose
  5. 1 module Components
  6. 1 class ImageComponent
  7. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  8. # 'src' is the official attribute for images per wiki
  9. 10 image_name = json_data['src'] || 'placeholder'
  10. # Add required imports
  11. 10 required_imports&.add(:image)
  12. 10 required_imports&.add(:painter_resource)
  13. 10 required_imports&.add(:r_class)
  14. 10 code = indent("Image(", depth)
  15. 10 code += "\n" + indent("painter = painterResource(id = R.drawable.#{image_name}),", depth + 1)
  16. # Content description for accessibility
  17. 10 content_desc = json_data['contentDescription'] || ''
  18. 10 code += "\n" + indent("contentDescription = #{quote(content_desc)},", depth + 1)
  19. # Build modifiers
  20. 10 modifiers = []
  21. # Size handling
  22. 10 if json_data['width'] && json_data['height']
  23. 1 modifiers << ".size(#{json_data['width']}.dp, #{json_data['height']}.dp)"
  24. 9 elsif json_data['size']
  25. 1 modifiers << ".size(#{json_data['size']}.dp)"
  26. else
  27. 8 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  28. end
  29. 10 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  30. 10 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  31. 10 code += Helpers::ModifierBuilder.format(modifiers, depth)
  32. # Content mode
  33. 10 if json_data['contentMode']
  34. 3 required_imports&.add(:content_scale)
  35. 3 case json_data['contentMode'].downcase
  36. when 'aspectfill'
  37. 1 code += ",\n" + indent("contentScale = ContentScale.Crop", depth + 1)
  38. when 'aspectfit'
  39. 1 code += ",\n" + indent("contentScale = ContentScale.Fit", depth + 1)
  40. when 'center'
  41. 1 code += ",\n" + indent("contentScale = ContentScale.None", depth + 1)
  42. end
  43. end
  44. 10 code += "\n" + indent(")", depth)
  45. 10 code
  46. end
  47. 1 private
  48. 1 def self.quote(text)
  49. 10 "\"#{text.gsub('"', '\\"')}\""
  50. end
  51. 1 def self.indent(text, level)
  52. 43 return text if level == 0
  53. 23 spaces = ' ' * level
  54. 23 text.split("\n").map { |line|
  55. 23 line.empty? ? line : spaces + line
  56. }.join("\n")
  57. end
  58. end
  59. end
  60. end
  61. end

lib/compose/components/indicator_component.rb

64.29% lines covered

56 relevant lines. 36 lines covered and 20 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class IndicatorComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Indicator can be circular or linear based on style
  10. 3 style = json_data['style'] || 'medium'
  11. 3 is_animating = json_data['animating']
  12. # Check if animating is controlled by data binding
  13. 3 show_condition = if is_animating && is_animating.is_a?(String) && is_animating.match(/@\{([^}]+)\}/)
  14. variable = $1
  15. "data.#{variable}"
  16. 3 elsif is_animating == false
  17. 'false'
  18. else
  19. 3 'true'
  20. end
  21. # Wrap in if condition if controlled by animating attribute
  22. 3 if is_animating != nil
  23. code = indent("if (#{show_condition}) {", depth)
  24. actual_depth = depth + 1
  25. else
  26. 3 code = ""
  27. 3 actual_depth = depth
  28. end
  29. # Determine indicator type based on style
  30. 3 if style == 'linear'
  31. code += "\n" if is_animating != nil
  32. code += indent("LinearProgressIndicator(", actual_depth)
  33. else
  34. 3 code += "\n" if is_animating != nil
  35. 3 code += indent("CircularProgressIndicator(", actual_depth)
  36. end
  37. # Build modifiers
  38. 3 modifiers = []
  39. # Size based on style
  40. 3 if style == 'large'
  41. modifiers << ".size(48.dp)"
  42. 3 elsif style == 'small'
  43. modifiers << ".size(16.dp)"
  44. 3 elsif json_data['size']
  45. modifiers << ".size(#{json_data['size']}.dp)"
  46. end
  47. 3 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  48. 3 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  49. 3 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  50. # Add weight modifier if in Row or Column
  51. 3 if parent_type == 'Row' || parent_type == 'Column'
  52. modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  53. end
  54. 3 code += Helpers::ModifierBuilder.format(modifiers, actual_depth) if modifiers.any?
  55. # Color
  56. 3 if json_data['color']
  57. color_resolved = Helpers::ResourceResolver.process_color(json_data['color'], required_imports)
  58. code += ",\n" + indent("color = #{color_resolved}", actual_depth + 1)
  59. end
  60. # Track color for linear progress
  61. 3 if style == 'linear' && json_data['trackColor']
  62. trackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['trackColor'], required_imports)
  63. code += ",\n" + indent("trackColor = #{trackcolor_resolved}", actual_depth + 1)
  64. end
  65. # Stroke width for circular progress
  66. 3 if style != 'linear' && json_data['strokeWidth']
  67. code += ",\n" + indent("strokeWidth = #{json_data['strokeWidth']}.dp", actual_depth + 1)
  68. end
  69. 3 code += "\n" + indent(")", actual_depth)
  70. # Close if condition
  71. 3 if is_animating != nil
  72. code += "\n" + indent("}", depth)
  73. end
  74. 3 code
  75. end
  76. 1 private
  77. 1 def self.indent(text, level)
  78. 6 return text if level == 0
  79. spaces = ' ' * level
  80. text.split("\n").map { |line|
  81. line.empty? ? line : spaces + line
  82. }.join("\n")
  83. end
  84. end
  85. end
  86. end
  87. end

lib/compose/components/networkimage_component.rb

74.14% lines covered

58 relevant lines. 43 lines covered and 15 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class NetworkImageComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. 5 required_imports&.add(:async_image)
  10. # NetworkImage uses 'source' or 'url' for image URL
  11. 5 url = process_data_binding(json_data['source'] || json_data['url'] || json_data['src'] || '')
  12. 5 placeholder = json_data['placeholder']
  13. 5 content_description = json_data['contentDescription'] || 'Image'
  14. 5 code = indent("AsyncImage(", depth)
  15. 5 code += "\n" + indent("model = #{url},", depth + 1)
  16. 5 code += "\n" + indent("contentDescription = \"#{content_description}\",", depth + 1)
  17. # Content scale
  18. 5 if json_data['contentMode']
  19. required_imports&.add(:content_scale)
  20. scale = case json_data['contentMode']
  21. when 'aspectFit'
  22. 'ContentScale.Fit'
  23. when 'aspectFill'
  24. 'ContentScale.Crop'
  25. when 'fill', 'scaleToFill'
  26. 'ContentScale.FillBounds'
  27. when 'center'
  28. 'ContentScale.None'
  29. else
  30. 'ContentScale.Fit'
  31. end
  32. code += "\n" + indent("contentScale = #{scale},", depth + 1)
  33. end
  34. # Placeholder
  35. 5 if placeholder
  36. 1 code += "\n" + indent("placeholder = painterResource(R.drawable.#{placeholder.gsub('.png', '').gsub('.jpg', '')}),", depth + 1)
  37. end
  38. # Build modifiers
  39. 5 modifiers = []
  40. # Handle size
  41. 5 if json_data['size']
  42. # size is a single value for both width and height
  43. modifiers << ".size(#{json_data['size']}.dp)"
  44. else
  45. 5 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  46. end
  47. # Corner radius for rounded images
  48. 5 if json_data['cornerRadius']
  49. required_imports&.add(:shape)
  50. modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
  51. end
  52. # Border
  53. 5 if json_data['borderWidth'] && json_data['borderColor']
  54. required_imports&.add(:border)
  55. shape = json_data['cornerRadius'] ? "RoundedCornerShape(#{json_data['cornerRadius']}.dp)" : "RectangleShape"
  56. modifiers << ".border(#{json_data['borderWidth']}.dp, Helpers::ResourceResolver.process_color('#{json_data['borderColor']}', required_imports), #{shape})"
  57. end
  58. 5 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  59. 5 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  60. 5 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  61. 5 code += Helpers::ModifierBuilder.format(modifiers, depth)
  62. # Error handling
  63. 5 if json_data['errorImage']
  64. code += ",\n" + indent("error = painterResource(R.drawable.#{json_data['errorImage'].gsub('.png', '').gsub('.jpg', '')})", depth + 1)
  65. end
  66. 5 code += "\n" + indent(")", depth)
  67. 5 code
  68. end
  69. 1 private
  70. 1 def self.process_data_binding(text)
  71. 5 return quote(text) unless text.is_a?(String)
  72. 5 if text.match(/@\{([^}]+)\}/)
  73. 1 variable = $1
  74. 1 "data.#{variable}"
  75. else
  76. 4 quote(text)
  77. end
  78. end
  79. 1 def self.quote(text)
  80. 4 "\"#{text.gsub('"', '\\"')}\""
  81. end
  82. 1 def self.indent(text, level)
  83. 21 return text if level == 0
  84. 11 spaces = ' ' * level
  85. 11 text.split("\n").map { |line|
  86. 11 line.empty? ? line : spaces + line
  87. }.join("\n")
  88. end
  89. end
  90. end
  91. end
  92. end

lib/compose/components/progress_component.rb

97.87% lines covered

47 relevant lines. 46 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class ProgressComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Progress can have a value (determinate) or be indeterminate
  10. 15 has_value = json_data['value'] || json_data['bind']
  11. 15 if has_value
  12. # Determinate progress (LinearProgressIndicator)
  13. 3 value = if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  14. 1 variable = $1
  15. 1 "data.#{variable}.toFloat()"
  16. 2 elsif json_data['value'] && json_data['value'].match(/@\{([^}]+)\}/)
  17. 1 variable = $1
  18. 1 "data.#{variable}.toFloat()"
  19. 1 elsif json_data['value']
  20. 1 "#{json_data['value']}f"
  21. else
  22. '0f'
  23. end
  24. 3 code = indent("LinearProgressIndicator(", depth)
  25. 3 code += "\n" + indent("progress = { #{value} },", depth + 1)
  26. else
  27. # Indeterminate progress
  28. 12 style = json_data['style'] || 'linear'
  29. 12 if style == 'circular' || style == 'large'
  30. 2 code = indent("CircularProgressIndicator(", depth)
  31. else
  32. 10 code = indent("LinearProgressIndicator(", depth)
  33. end
  34. end
  35. # Build modifiers
  36. 15 modifiers = []
  37. 15 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  38. 15 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  39. 15 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  40. 15 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  41. # Progress colors
  42. 15 if json_data['progressTintColor'] || json_data['trackTintColor']
  43. 3 colors_params = []
  44. 3 if json_data['progressTintColor']
  45. 2 color_resolved = Helpers::ResourceResolver.process_color(json_data['progressTintColor'], required_imports)
  46. 2 colors_params << "color = #{color_resolved}"
  47. end
  48. 3 if json_data['trackTintColor']
  49. 2 trackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['trackTintColor'], required_imports)
  50. 2 colors_params << "trackColor = #{trackcolor_resolved}"
  51. end
  52. 3 if colors_params.any?
  53. 7 code += ",\n" + colors_params.map { |param| indent(param, depth + 1) }.join(",\n")
  54. end
  55. end
  56. 15 code += "\n" + indent(")", depth)
  57. 15 code
  58. end
  59. 1 private
  60. 1 def self.indent(text, level)
  61. 41 return text if level == 0
  62. 10 spaces = ' ' * level
  63. 10 text.split("\n").map { |line|
  64. 13 line.empty? ? line : spaces + line
  65. }.join("\n")
  66. end
  67. end
  68. end
  69. end
  70. end

lib/compose/components/radio_component.rb

93.24% lines covered

207 relevant lines. 193 lines covered and 14 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class RadioComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Handle Radio group with items FIRST (higher priority)
  10. 15 if json_data['items']
  11. 3 return generate_radio_group_with_items(json_data, depth, required_imports, parent_type)
  12. end
  13. # Handle individual Radio item (not a group)
  14. 12 if json_data['group'] || json_data['text']
  15. 6 return generate_radio_item(json_data, depth, required_imports, parent_type)
  16. end
  17. # Radio uses 'bind' for selected value
  18. 6 selected = if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  19. 4 variable = $1
  20. 4 "data.#{variable}"
  21. else
  22. 2 '""'
  23. end
  24. 6 code = indent("Column(", depth)
  25. # Build modifiers
  26. 6 modifiers = []
  27. 6 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  28. 6 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  29. 6 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  30. 6 code += "\n" + indent(") {", depth)
  31. # Radio options
  32. 6 if json_data['options']
  33. 5 if json_data['options'].is_a?(Array)
  34. 4 json_data['options'].each do |option|
  35. 9 option_value = option.is_a?(Hash) ? option['value'] : option
  36. 9 option_label = option.is_a?(Hash) ? option['label'] : option
  37. 9 code += "\n" + indent("Row(", depth + 1)
  38. 9 code += "\n" + indent("verticalAlignment = Alignment.CenterVertically,", depth + 2)
  39. 9 code += "\n" + indent("modifier = Modifier", depth + 2)
  40. 9 code += "\n" + indent(" .fillMaxWidth()", depth + 2)
  41. 9 code += "\n" + indent(" .clickable {", depth + 2)
  42. 9 if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  43. 7 variable = $1
  44. 7 code += "\n" + indent(" viewModel.updateData(mapOf(\"#{variable}\" to \"#{option_value}\"))", depth + 2)
  45. 2 elsif json_data['onValueChange']
  46. 2 code += "\n" + indent(" viewModel.#{json_data['onValueChange']}(\"#{option_value}\")", depth + 2)
  47. end
  48. 9 code += "\n" + indent(" }", depth + 2)
  49. 9 code += "\n" + indent(") {", depth + 1)
  50. # RadioButton
  51. 9 code += "\n" + indent("RadioButton(", depth + 2)
  52. 9 code += "\n" + indent("selected = (#{selected} == \"#{option_value}\"),", depth + 3)
  53. 9 code += "\n" + indent("onClick = {", depth + 3)
  54. 9 if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  55. 7 variable = $1
  56. 7 code += "\n" + indent("viewModel.updateData(mapOf(\"#{variable}\" to \"#{option_value}\"))", depth + 4)
  57. 2 elsif json_data['onValueChange']
  58. 2 code += "\n" + indent("viewModel.#{json_data['onValueChange']}(\"#{option_value}\")", depth + 4)
  59. end
  60. 9 code += "\n" + indent("}", depth + 3)
  61. # RadioButton colors
  62. 9 if json_data['selectedColor'] || json_data['unselectedColor']
  63. 2 required_imports&.add(:radio_colors)
  64. 2 colors_params = []
  65. 2 if json_data['selectedColor']
  66. 2 selectedcolor_resolved = Helpers::ResourceResolver.process_color(json_data['selectedColor'], required_imports)
  67. 2 colors_params << "selectedColor = #{selectedcolor_resolved}"
  68. end
  69. 2 if json_data['unselectedColor']
  70. 2 unselectedcolor_resolved = Helpers::ResourceResolver.process_color(json_data['unselectedColor'], required_imports)
  71. 2 colors_params << "unselectedColor = #{unselectedcolor_resolved}"
  72. end
  73. 2 if colors_params.any?
  74. 2 code += ",\n" + indent("colors = RadioButtonDefaults.colors(", depth + 3)
  75. 6 code += "\n" + colors_params.map { |param| indent(param, depth + 4) }.join(",\n")
  76. 2 code += "\n" + indent(")", depth + 3)
  77. end
  78. end
  79. 9 code += "\n" + indent(")", depth + 2)
  80. # Label text
  81. 9 code += "\n" + indent("Spacer(modifier = Modifier.width(8.dp))", depth + 2)
  82. 9 code += "\n" + indent("Text(\"#{option_label}\")", depth + 2)
  83. 9 code += "\n" + indent("}", depth + 1)
  84. end
  85. 1 elsif json_data['options'].is_a?(String) && json_data['options'].match(/@\{([^}]+)\}/)
  86. # Dynamic options from data binding
  87. 1 options_var = $1
  88. 1 code += "\n" + indent("data.#{options_var}.forEach { option ->", depth + 1)
  89. 1 code += "\n" + indent("Row(", depth + 2)
  90. 1 code += "\n" + indent("verticalAlignment = Alignment.CenterVertically,", depth + 3)
  91. 1 code += "\n" + indent("modifier = Modifier.fillMaxWidth().clickable {", depth + 3)
  92. 1 if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  93. 1 variable = $1
  94. 1 code += "\n" + indent("viewModel.updateData(mapOf(\"#{variable}\" to option))", depth + 4)
  95. end
  96. 1 code += "\n" + indent("}", depth + 3)
  97. 1 code += "\n" + indent(") {", depth + 2)
  98. 1 code += "\n" + indent("RadioButton(", depth + 3)
  99. 1 code += "\n" + indent("selected = (#{selected} == option),", depth + 4)
  100. 1 code += "\n" + indent("onClick = {", depth + 4)
  101. 1 if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  102. 1 variable = $1
  103. 1 code += "\n" + indent("viewModel.updateData(mapOf(\"#{variable}\" to option))", depth + 5)
  104. end
  105. 1 code += "\n" + indent("}", depth + 4)
  106. 1 code += "\n" + indent(")", depth + 3)
  107. 1 code += "\n" + indent("Spacer(modifier = Modifier.width(8.dp))", depth + 3)
  108. 1 code += "\n" + indent("Text(option)", depth + 3)
  109. 1 code += "\n" + indent("}", depth + 2)
  110. 1 code += "\n" + indent("}", depth + 1)
  111. end
  112. end
  113. 6 code += "\n" + indent("}", depth)
  114. 6 code
  115. end
  116. 1 private
  117. 1 def self.generate_radio_item(json_data, depth, required_imports, parent_type)
  118. 6 group = json_data['group'] || 'default'
  119. 6 id = json_data['id'] || "radio_#{rand(1000)}"
  120. 6 text = json_data['text'] || ''
  121. # Get the selected state from binding
  122. 6 selected_var = "selectedRadiogroup" # Default variable name
  123. 6 if group.downcase != 'default'
  124. # Use group name as part of the variable
  125. 1 selected_var = "selected#{group.capitalize}"
  126. end
  127. 6 code = indent("Row(", depth)
  128. 6 code += "\n" + indent(" verticalAlignment = Alignment.CenterVertically,", depth)
  129. # Build modifiers
  130. 6 modifiers = []
  131. 6 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  132. 6 if modifiers.any?
  133. code += "\n" + indent(" modifier = Modifier", depth)
  134. modifiers.each do |mod|
  135. code += "\n" + indent(" #{mod}", depth)
  136. end
  137. end
  138. 6 code += "\n" + indent(") {", depth)
  139. # Handle custom icons or default components
  140. # If icon is "circle" or selectedIcon is "checkmark.circle.fill", use default RadioButton
  141. 6 if (json_data['icon'] == 'circle' || !json_data['icon']) &&
  142. (json_data['selectedIcon'] == 'checkmark.circle.fill' || !json_data['selectedIcon'])
  143. # Use default RadioButton for standard radio appearance
  144. 3 code += "\n" + indent(" RadioButton(", depth)
  145. 3 code += "\n" + indent(" selected = data.#{selected_var} == \"#{id}\",", depth)
  146. 3 code += "\n" + indent(" onClick = { viewModel.updateData(mapOf(\"#{selected_var}\" to \"#{id}\")) }", depth)
  147. 3 code += "\n" + indent(" )", depth)
  148. 3 elsif json_data['icon'] == 'square' &&
  149. (json_data['selectedIcon'] == 'checkmark.square.fill' || !json_data['selectedIcon'])
  150. # Use default Checkbox for square appearance
  151. 1 required_imports&.add(:checkbox)
  152. 1 code += "\n" + indent(" Checkbox(", depth)
  153. 1 code += "\n" + indent(" checked = data.#{selected_var} == \"#{id}\",", depth)
  154. 1 code += "\n" + indent(" onCheckedChange = { viewModel.updateData(mapOf(\"#{selected_var}\" to \"#{id}\")) }", depth)
  155. 1 code += "\n" + indent(" )", depth)
  156. 2 elsif json_data['icon'] || json_data['selectedIcon']
  157. # Use IconButton with custom icons only for non-standard icons
  158. 2 required_imports&.add(:icon_button)
  159. 2 required_imports&.add(:icons)
  160. 2 icon = map_icon_name(json_data['icon'] || 'star')
  161. 2 selected_icon = map_icon_name(json_data['selectedIcon'] || 'star.fill')
  162. 2 code += "\n" + indent(" val isSelected = data.#{selected_var} == \"#{id}\"", depth)
  163. 2 code += "\n" + indent(" IconButton(", depth)
  164. 2 code += "\n" + indent(" onClick = { viewModel.updateData(mapOf(\"#{selected_var}\" to \"#{id}\")) }", depth)
  165. 2 code += "\n" + indent(" ) {", depth)
  166. 2 code += "\n" + indent(" Icon(", depth)
  167. 2 code += "\n" + indent(" imageVector = if (isSelected) #{selected_icon} else #{icon},", depth)
  168. 2 code += "\n" + indent(" contentDescription = \"#{text}\",", depth)
  169. 2 if json_data['selectedColor'] || json_data['tintColor']
  170. 1 color = json_data['selectedColor'] || json_data['tintColor']
  171. 1 selected_color = Helpers::ResourceResolver.process_color(color, required_imports)
  172. 1 code += "\n" + indent(" tint = if (isSelected) #{selected_color} else Color.Gray", depth)
  173. else
  174. 1 code += "\n" + indent(" tint = if (isSelected) MaterialTheme.colorScheme.primary else Color.Gray", depth)
  175. end
  176. 2 code += "\n" + indent(" )", depth)
  177. 2 code += "\n" + indent(" }", depth)
  178. else
  179. # Default RadioButton
  180. code += "\n" + indent(" RadioButton(", depth)
  181. code += "\n" + indent(" selected = data.#{selected_var} == \"#{id}\",", depth)
  182. code += "\n" + indent(" onClick = { viewModel.updateData(mapOf(\"#{selected_var}\" to \"#{id}\")) }", depth)
  183. code += "\n" + indent(" )", depth)
  184. end
  185. # Add text label
  186. 6 if text && !text.empty?
  187. 6 code += "\n" + indent(" Spacer(modifier = Modifier.width(8.dp))", depth)
  188. # Add text with color
  189. 6 if json_data['fontColor'] || json_data['textColor']
  190. 1 text_color = json_data['fontColor'] || json_data['textColor']
  191. 1 color_resolved = Helpers::ResourceResolver.process_color(text_color, required_imports)
  192. 1 code += "\n" + indent(" Text(\"#{text}\", color = #{color_resolved})", depth)
  193. else
  194. # Default to black color
  195. 5 code += "\n" + indent(" Text(\"#{text}\", color = Color.Black)", depth)
  196. end
  197. end
  198. 6 code += "\n" + indent("}", depth)
  199. 6 code
  200. end
  201. 1 def self.generate_radio_group_with_items(json_data, depth, required_imports, parent_type)
  202. 3 items = json_data['items']
  203. 3 selected_value = json_data['selectedValue']
  204. # Add required import for clickable
  205. 3 required_imports&.add(:clickable)
  206. # Extract binding variable
  207. 3 selected_var = if selected_value && selected_value.match(/@\{([^}]+)\}/)
  208. 3 "data.#{$1}"
  209. else
  210. '""'
  211. end
  212. 3 code = indent("Column(", depth)
  213. # Build modifiers
  214. 3 modifiers = []
  215. 3 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  216. 3 if modifiers.any?
  217. code += "\n" + indent(" modifier = Modifier", depth)
  218. modifiers.each do |mod|
  219. code += "\n" + indent(" #{mod}", depth)
  220. end
  221. end
  222. 3 code += "\n" + indent(") {", depth)
  223. # Add label if present
  224. 3 if json_data['text']
  225. 1 if json_data['fontColor'] || json_data['textColor']
  226. text_color = json_data['fontColor'] || json_data['textColor']
  227. color_resolved = Helpers::ResourceResolver.process_color(text_color, required_imports)
  228. code += "\n" + indent(" Text(\"#{json_data['text']}\", color = #{color_resolved})", depth)
  229. else
  230. # Default to black color
  231. 1 code += "\n" + indent(" Text(\"#{json_data['text']}\", color = Color.Black)", depth)
  232. end
  233. 1 code += "\n" + indent(" Spacer(modifier = Modifier.height(8.dp))", depth)
  234. end
  235. # Generate radio items
  236. 3 items.each do |item|
  237. 7 code += "\n" + indent(" Row(", depth)
  238. 7 code += "\n" + indent(" verticalAlignment = Alignment.CenterVertically,", depth)
  239. 7 code += "\n" + indent(" modifier = Modifier", depth)
  240. 7 code += "\n" + indent(" .fillMaxWidth()", depth)
  241. 7 code += "\n" + indent(" .clickable {", depth)
  242. 7 if selected_value && selected_value.match(/@\{([^}]+)\}/)
  243. 7 variable = $1
  244. 7 code += "\n" + indent(" viewModel.updateData(mapOf(\"#{variable}\" to \"#{item}\"))", depth)
  245. end
  246. 7 code += "\n" + indent(" }", depth)
  247. 7 code += "\n" + indent(" ) {", depth)
  248. 7 code += "\n" + indent(" RadioButton(", depth)
  249. 7 code += "\n" + indent(" selected = #{selected_var} == \"#{item}\",", depth)
  250. 7 code += "\n" + indent(" onClick = {", depth)
  251. 7 if selected_value && selected_value.match(/@\{([^}]+)\}/)
  252. 7 variable = $1
  253. 7 code += "\n" + indent(" viewModel.updateData(mapOf(\"#{variable}\" to \"#{item}\"))", depth)
  254. end
  255. 7 code += "\n" + indent(" }", depth)
  256. 7 code += "\n" + indent(" )", depth)
  257. 7 code += "\n" + indent(" Spacer(modifier = Modifier.width(8.dp))", depth)
  258. # Add text with black color
  259. 7 if json_data['fontColor'] || json_data['textColor']
  260. 2 text_color = json_data['fontColor'] || json_data['textColor']
  261. 2 color_resolved = Helpers::ResourceResolver.process_color(text_color, required_imports)
  262. 2 code += "\n" + indent(" Text(\"#{item}\", color = #{color_resolved})", depth)
  263. else
  264. # Default to black color
  265. 5 code += "\n" + indent(" Text(\"#{item}\", color = Color.Black)", depth)
  266. end
  267. 7 code += "\n" + indent(" }", depth)
  268. end
  269. 3 code += "\n" + indent("}", depth)
  270. 3 code
  271. end
  272. 1 def self.map_icon_name(icon_name)
  273. # Map iOS SF Symbols to Material Icons
  274. 9 icon_map = {
  275. 'circle' => 'Icons.Outlined.PanoramaFishEye', # Using PanoramaFishEye as it's a hollow circle
  276. 'checkmark.circle.fill' => 'Icons.Filled.CheckCircle',
  277. 'star' => 'Icons.Outlined.Star',
  278. 'star.fill' => 'Icons.Filled.Star',
  279. 'heart' => 'Icons.Outlined.FavoriteBorder',
  280. 'heart.fill' => 'Icons.Filled.Favorite',
  281. 'square' => 'Icons.Outlined.CheckBoxOutlineBlank',
  282. 'checkmark.square.fill' => 'Icons.Default.CheckBox' # Use Default.CheckBox instead of Filled.CheckBox
  283. }
  284. 9 icon_map[icon_name] || 'Icons.Outlined.Star' # Default fallback to star
  285. end
  286. 1 def self.indent(text, level)
  287. 400 return text if level == 0
  288. 179 spaces = ' ' * level
  289. 179 text.split("\n").map { |line|
  290. 179 line.empty? ? line : spaces + line
  291. }.join("\n")
  292. end
  293. end
  294. end
  295. end
  296. end

lib/compose/components/scrollview_component.rb

100.0% lines covered

39 relevant lines. 39 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 module KjuiTools
  4. 1 module Compose
  5. 1 module Components
  6. 1 class ScrollViewComponent
  7. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  8. # スクロール方向の判定
  9. # horizontalScroll属性、orientation属性、またはchild要素の配置から判定
  10. 9 is_horizontal = false
  11. # 1. horizontalScroll属性を最優先
  12. 9 if json_data.key?('horizontalScroll')
  13. 1 is_horizontal = json_data['horizontalScroll']
  14. # 2. orientation属性を次に確認
  15. 8 elsif json_data.key?('orientation')
  16. 1 is_horizontal = json_data['orientation'] == 'horizontal'
  17. # 3. child要素の配置から判定
  18. 7 elsif json_data['child']
  19. 3 children = json_data['child']
  20. # childを配列として扱う
  21. 3 children = [children] unless children.is_a?(Array)
  22. # 配列の中から最初のViewコンポーネントを探す
  23. 6 first_view = children.find { |child| child.is_a?(Hash) && child['type'] == 'View' }
  24. 3 if first_view
  25. 1 is_horizontal = first_view['orientation'] == 'horizontal'
  26. end
  27. end
  28. 9 if is_horizontal
  29. 3 required_imports&.add(:lazy_row)
  30. 3 code = indent("LazyRow(", depth)
  31. else
  32. 6 required_imports&.add(:lazy_column)
  33. 6 code = indent("LazyColumn(", depth)
  34. end
  35. # Build modifiers
  36. 9 modifiers = []
  37. 9 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  38. 9 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  39. 9 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  40. 9 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  41. 9 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  42. 9 code += "\n" + indent(") {", depth)
  43. 9 code += "\n" + indent("item {", depth + 1)
  44. # Process children
  45. 9 children = json_data['child'] || []
  46. 9 children = [children] unless children.is_a?(Array)
  47. # Return structure for parent to process children
  48. {
  49. 9 code: code,
  50. children: children,
  51. closing: "\n" + indent("}", depth + 1) + "\n" + indent("}", depth)
  52. }
  53. end
  54. 1 private
  55. 1 def self.indent(text, level)
  56. 45 return text if level == 0
  57. 18 spaces = ' ' * level
  58. 18 text.split("\n").map { |line|
  59. 18 line.empty? ? line : spaces + line
  60. }.join("\n")
  61. end
  62. end
  63. end
  64. end
  65. end

lib/compose/components/segment_component.rb

80.5% lines covered

159 relevant lines. 128 lines covered and 31 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class SegmentComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. 22 required_imports&.add(:segment)
  10. # Segment uses 'selectedIndex' or 'bind' for selected index
  11. # Track if the selected index is dynamic (from data binding) or static
  12. 22 is_dynamic_index = false
  13. 22 selected_index = if json_data['selectedIndex']
  14. 5 if json_data['selectedIndex'].is_a?(String) && json_data['selectedIndex'].match(/@\{([^}]+)\}/)
  15. 3 variable = $1
  16. 3 is_dynamic_index = true
  17. 3 "data.#{variable}"
  18. else
  19. # Direct integer value - keep as integer for proper comparison
  20. 2 json_data['selectedIndex'].to_i
  21. end
  22. 17 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  23. 1 variable = $1
  24. 1 is_dynamic_index = true
  25. 1 "data.#{variable}"
  26. else
  27. 16 0 # Default to 0 as integer
  28. end
  29. # Support both 'items' and 'segments' attribute names
  30. 22 segments = json_data['items'] || json_data['segments'] || []
  31. 22 code = indent("Segment(", depth)
  32. # For display in Segment parameter, always output as string
  33. 22 selected_tab_param = is_dynamic_index ? selected_index : selected_index.to_s
  34. 22 code += "\n" + indent("selectedTabIndex = #{selected_tab_param},", depth + 1)
  35. # Add enabled state if specified
  36. 22 if json_data.key?('enabled')
  37. 2 enabled_value = json_data['enabled']
  38. 2 if enabled_value.is_a?(String) && enabled_value.match(/@\{([^}]+)\}/)
  39. 1 code += "\n" + indent("enabled = data.#{$1},", depth + 1)
  40. else
  41. 1 code += "\n" + indent("enabled = #{enabled_value},", depth + 1)
  42. end
  43. end
  44. # Tab colors - only add if specified, otherwise use defaults from Configuration
  45. 22 colors_params = []
  46. # Background color (containerColor)
  47. 22 if json_data['backgroundColor']
  48. 1 bg_color = Helpers::ResourceResolver.process_color(json_data['backgroundColor'], required_imports)
  49. 1 colors_params << "containerColor = #{bg_color}"
  50. end
  51. # Normal text color (contentColor) - for unselected tabs
  52. 22 if json_data['normalColor']
  53. 3 normal_color = Helpers::ResourceResolver.process_color(json_data['normalColor'], required_imports)
  54. 3 colors_params << "contentColor = #{normal_color}"
  55. end
  56. # Selected text color (selectedContentColor)
  57. 22 if json_data['selectedColor'] || json_data['tintColor'] || json_data['selectedSegmentTintColor']
  58. 4 color = json_data['selectedColor'] || json_data['tintColor'] || json_data['selectedSegmentTintColor']
  59. 4 selected_color = Helpers::ResourceResolver.process_color(color, required_imports)
  60. 4 colors_params << "selectedContentColor = #{selected_color}"
  61. end
  62. # Indicator color - only if specified
  63. 22 if json_data['indicatorColor']
  64. 1 indicator_color = Helpers::ResourceResolver.process_color(json_data['indicatorColor'], required_imports)
  65. 1 colors_params << "indicatorColor = #{indicator_color}"
  66. end
  67. 22 if colors_params.any?
  68. 7 code += "\n" + indent(colors_params.join(",\n"), depth + 1) + ","
  69. end
  70. # Build modifiers
  71. 22 modifiers = []
  72. 22 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  73. 22 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  74. 22 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  75. 22 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  76. 22 code += "\n" + indent(") {", depth)
  77. # Generate tabs
  78. 22 if segments.is_a?(Array)
  79. 21 segments.each_with_index do |segment, index|
  80. 41 code += "\n" + indent("Tab(", depth + 1)
  81. # For selected comparison, handle both dynamic and static cases
  82. 41 selected_comparison = is_dynamic_index ? "(#{selected_index} == #{index})" : (selected_index == index).to_s
  83. 41 code += "\n" + indent("selected = #{selected_comparison},", depth + 2)
  84. # Add enabled state to Tab if segment is disabled
  85. 41 if json_data.key?('enabled')
  86. 4 enabled_value = json_data['enabled']
  87. 4 if enabled_value.is_a?(String) && enabled_value.match(/@\{([^}]+)\}/)
  88. 2 code += "\n" + indent("enabled = data.#{$1},", depth + 2)
  89. else
  90. 2 code += "\n" + indent("enabled = #{enabled_value},", depth + 2)
  91. end
  92. end
  93. 41 code += "\n" + indent("onClick = {", depth + 2)
  94. # Check if we have a binding variable
  95. 41 has_binding = false
  96. 41 binding_variable = nil
  97. 41 if json_data['selectedIndex'] && json_data['selectedIndex'].is_a?(String) && json_data['selectedIndex'].match(/@\{([^}]+)\}/)
  98. 6 has_binding = true
  99. 6 binding_variable = $1
  100. 35 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  101. 2 has_binding = true
  102. 2 binding_variable = $1
  103. end
  104. # Generate onClick handler
  105. 41 if json_data['onValueChange']
  106. # Use custom handler if specified
  107. 2 code += "\n" + indent("viewModel.#{json_data['onValueChange']}(#{index})", depth + 3)
  108. 39 elsif has_binding
  109. # Update the bound variable
  110. 8 code += "\n" + indent("viewModel.updateData(mapOf(\"#{binding_variable}\" to #{index}))", depth + 3)
  111. else
  112. # No action if selectedIndex is a static value with no binding
  113. 31 code += "\n" + indent("// Static selected index", depth + 3)
  114. end
  115. 41 code += "\n" + indent("},", depth + 2)
  116. # Generate text with color based on selection
  117. # Store color info for later use
  118. 41 normal_color = json_data['normalColor']
  119. 41 selected_color = json_data['selectedColor'] || json_data['tintColor'] || json_data['selectedSegmentTintColor']
  120. 41 if normal_color || selected_color
  121. # Need to handle text color based on selection
  122. 10 code += "\n" + indent("text = {", depth + 2)
  123. 10 code += "\n" + indent("Text(", depth + 3)
  124. 10 code += "\n" + indent("\"#{segment}\",", depth + 4)
  125. # Use conditional color based on selection
  126. 10 if is_dynamic_index
  127. 2 if selected_color && normal_color
  128. 2 selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
  129. 2 normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
  130. 2 code += "\n" + indent("color = if (#{selected_index} == #{index}) #{selected_resolved} else #{normal_resolved}", depth + 4)
  131. elsif selected_color
  132. selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
  133. code += "\n" + indent("color = if (#{selected_index} == #{index}) #{selected_resolved} else Color.Unspecified", depth + 4)
  134. elsif normal_color
  135. normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
  136. code += "\n" + indent("color = if (#{selected_index} == #{index}) Color.Unspecified else #{normal_resolved}", depth + 4)
  137. end
  138. else
  139. # Static index
  140. 8 is_selected = (selected_index == index)
  141. 8 if is_selected && selected_color
  142. 3 selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
  143. 3 code += "\n" + indent("color = #{selected_resolved}", depth + 4)
  144. 5 elsif !is_selected && normal_color
  145. 2 normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
  146. 2 code += "\n" + indent("color = #{normal_resolved}", depth + 4)
  147. end
  148. end
  149. 10 code += "\n" + indent(")", depth + 3)
  150. 10 code += "\n" + indent("}", depth + 2)
  151. else
  152. 31 code += "\n" + indent("text = { Text(\"#{segment}\") }", depth + 2)
  153. end
  154. 41 code += "\n" + indent(")", depth + 1)
  155. end
  156. 1 elsif segments.is_a?(String) && segments.match(/@\{([^}]+)\}/)
  157. # Dynamic segments from data binding
  158. 1 segments_var = $1
  159. 1 code += "\n" + indent("data.#{segments_var}.forEachIndexed { index, segment ->", depth + 1)
  160. 1 code += "\n" + indent("Tab(", depth + 2)
  161. # For dynamic segments, selected_index comparison depends on whether the index itself is dynamic
  162. 1 selected_comparison = is_dynamic_index ? "(#{selected_index} == index)" : "(#{selected_index} == index)"
  163. 1 code += "\n" + indent("selected = #{selected_comparison},", depth + 3)
  164. # Add enabled state to Tab if segment is disabled
  165. 1 if json_data.key?('enabled')
  166. enabled_value = json_data['enabled']
  167. if enabled_value.is_a?(String) && enabled_value.match(/@\{([^}]+)\}/)
  168. code += "\n" + indent("enabled = data.#{$1},", depth + 3)
  169. else
  170. code += "\n" + indent("enabled = #{enabled_value},", depth + 3)
  171. end
  172. end
  173. 1 code += "\n" + indent("onClick = {", depth + 3)
  174. # Check if we have a binding variable
  175. 1 has_binding = false
  176. 1 binding_variable = nil
  177. 1 if json_data['selectedIndex'] && json_data['selectedIndex'].is_a?(String) && json_data['selectedIndex'].match(/@\{([^}]+)\}/)
  178. has_binding = true
  179. binding_variable = $1
  180. 1 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  181. has_binding = true
  182. binding_variable = $1
  183. end
  184. # Generate onClick handler
  185. 1 if json_data['onValueChange']
  186. # Use custom handler if specified
  187. code += "\n" + indent("viewModel.#{json_data['onValueChange']}(index)", depth + 4)
  188. 1 elsif has_binding
  189. # Update the bound variable
  190. code += "\n" + indent("viewModel.updateData(mapOf(\"#{binding_variable}\" to index))", depth + 4)
  191. else
  192. # No action if selectedIndex is a static value with no binding
  193. 1 code += "\n" + indent("// Static selected index", depth + 4)
  194. end
  195. 1 code += "\n" + indent("},", depth + 3)
  196. # Generate text with color based on selection for dynamic segments
  197. 1 normal_color = json_data['normalColor']
  198. 1 selected_color = json_data['selectedColor'] || json_data['tintColor'] || json_data['selectedSegmentTintColor']
  199. 1 if normal_color || selected_color
  200. code += "\n" + indent("text = {", depth + 3)
  201. code += "\n" + indent("Text(", depth + 4)
  202. code += "\n" + indent("segment,", depth + 5)
  203. # Use conditional color based on selection
  204. if selected_color && normal_color
  205. selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
  206. normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
  207. code += "\n" + indent("color = if (#{selected_comparison}) #{selected_resolved} else #{normal_resolved}", depth + 5)
  208. elsif selected_color
  209. selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
  210. code += "\n" + indent("color = if (#{selected_comparison}) #{selected_resolved} else Color.Unspecified", depth + 5)
  211. elsif normal_color
  212. normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
  213. code += "\n" + indent("color = if (#{selected_comparison}) Color.Unspecified else #{normal_resolved}", depth + 5)
  214. end
  215. code += "\n" + indent(")", depth + 4)
  216. code += "\n" + indent("}", depth + 3)
  217. else
  218. 1 code += "\n" + indent("text = { Text(segment) }", depth + 3)
  219. end
  220. 1 code += "\n" + indent(")", depth + 2)
  221. 1 code += "\n" + indent("}", depth + 1)
  222. end
  223. 22 code += "\n" + indent("}", depth)
  224. 22 code
  225. end
  226. 1 private
  227. 1 def self.indent(text, level)
  228. 446 return text if level == 0
  229. 379 spaces = ' ' * level
  230. 379 text.split("\n").map { |line|
  231. 381 line.empty? ? line : spaces + line
  232. }.join("\n")
  233. end
  234. end
  235. end
  236. end
  237. end

lib/compose/components/selectbox_component.rb

99.04% lines covered

104 relevant lines. 103 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class SelectBoxComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. 28 required_imports&.add(:selectbox_component)
  10. # Check if this is a date picker
  11. 28 is_date_picker = json_data['selectItemType'] == 'Date'
  12. # SelectBox uses 'selectedItem' or 'bind' for selected value
  13. 28 selected = if json_data['selectedItem'] && json_data['selectedItem'].match(/@\{([^}]+)\}/)
  14. 1 variable = $1
  15. 1 "data.#{variable}"
  16. 27 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  17. 1 variable = $1
  18. 1 "data.#{variable}"
  19. else
  20. 26 '""'
  21. end
  22. # Use DateSelectBox for date type
  23. 28 if is_date_picker
  24. 9 required_imports&.add(:date_selectbox_component)
  25. 9 code = indent("DateSelectBox(", depth)
  26. else
  27. 19 code = indent("SelectBox(", depth)
  28. end
  29. 28 code += "\n" + indent("value = #{selected},", depth + 1)
  30. # Handle onValueChange callback
  31. 28 if json_data['selectedItem'] && json_data['selectedItem'].match(/@\{([^}]+)\}/)
  32. 1 variable = $1
  33. 1 code += "\n" + indent("onValueChange = { newValue ->", depth + 1)
  34. 1 code += "\n" + indent("viewModel.updateData(mapOf(\"#{variable}\" to newValue))", depth + 2)
  35. 1 code += "\n" + indent("},", depth + 1)
  36. 27 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  37. 1 variable = $1
  38. 1 code += "\n" + indent("onValueChange = { newValue ->", depth + 1)
  39. 1 code += "\n" + indent("viewModel.updateData(mapOf(\"#{variable}\" to newValue))", depth + 2)
  40. 1 code += "\n" + indent("},", depth + 1)
  41. else
  42. 26 code += "\n" + indent("onValueChange = { },", depth + 1)
  43. end
  44. # For date picker, add date-specific parameters
  45. 28 if is_date_picker
  46. # Date picker mode (date, time, dateAndTime)
  47. 9 if json_data['datePickerMode']
  48. 1 code += "\n" + indent("datePickerMode = \"#{json_data['datePickerMode']}\",", depth + 1)
  49. end
  50. # Date picker style
  51. 9 if json_data['datePickerStyle']
  52. 1 code += "\n" + indent("datePickerStyle = \"#{json_data['datePickerStyle']}\",", depth + 1)
  53. end
  54. # Date format (or dateStringFormat)
  55. 9 date_format = json_data['dateFormat'] || json_data['dateStringFormat']
  56. 9 if date_format
  57. 2 code += "\n" + indent("dateFormat = \"#{date_format}\",", depth + 1)
  58. end
  59. # Minute interval for time pickers
  60. 9 if json_data['minuteInterval']
  61. 1 code += "\n" + indent("minuteInterval = #{json_data['minuteInterval']},", depth + 1)
  62. end
  63. # Minimum date
  64. 9 if json_data['minimumDate']
  65. 1 code += "\n" + indent("minimumDate = \"#{json_data['minimumDate']}\",", depth + 1)
  66. end
  67. # Maximum date
  68. 9 if json_data['maximumDate']
  69. 1 code += "\n" + indent("maximumDate = \"#{json_data['maximumDate']}\",", depth + 1)
  70. end
  71. else
  72. # Options (use 'items' or 'options') - only for non-date SelectBox
  73. 19 options_data = json_data['items'] || json_data['options']
  74. 19 if options_data
  75. 6 if options_data.is_a?(String) && options_data.match(/@\{([^}]+)\}/)
  76. # Dynamic options from data binding
  77. 1 options_var = $1
  78. 1 code += "\n" + indent("options = data.#{options_var},", depth + 1)
  79. 5 elsif options_data.is_a?(Array)
  80. # Static options array
  81. 5 options_list = options_data.map do |option|
  82. 12 if option.is_a?(Hash)
  83. 2 "\"#{option['label'] || option['value']}\""
  84. else
  85. 10 "\"#{option}\""
  86. end
  87. end.join(", ")
  88. 5 code += "\n" + indent("options = listOf(#{options_list}),", depth + 1)
  89. else
  90. code += "\n" + indent("options = emptyList(),", depth + 1)
  91. end
  92. else
  93. 13 code += "\n" + indent("options = emptyList(),", depth + 1)
  94. end
  95. end
  96. # Add placeholder/hint if specified
  97. 28 if json_data['hint']
  98. 1 code += "\n" + indent("placeholder = \"#{json_data['hint']}\",", depth + 1)
  99. 27 elsif json_data['placeholder']
  100. 1 code += "\n" + indent("placeholder = \"#{json_data['placeholder']}\",", depth + 1)
  101. end
  102. # Add enabled state if specified
  103. 28 if json_data['disabled']
  104. 1 code += "\n" + indent("enabled = false,", depth + 1)
  105. 27 elsif json_data['enabled'] == false
  106. 1 code += "\n" + indent("enabled = false,", depth + 1)
  107. end
  108. # Add style parameters
  109. 28 if json_data['background']
  110. 1 bg_color = Helpers::ResourceResolver.process_color(json_data['background'], required_imports)
  111. 1 code += "\n" + indent("backgroundColor = #{bg_color},", depth + 1)
  112. end
  113. 28 if json_data['borderColor']
  114. 1 border_color = Helpers::ResourceResolver.process_color(json_data['borderColor'], required_imports)
  115. 1 code += "\n" + indent("borderColor = #{border_color},", depth + 1)
  116. end
  117. 28 if json_data['fontColor']
  118. 1 text_color = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  119. 1 code += "\n" + indent("textColor = #{text_color},", depth + 1)
  120. end
  121. 28 if json_data['hintColor']
  122. 1 hint_color = Helpers::ResourceResolver.process_color(json_data['hintColor'], required_imports)
  123. 1 code += "\n" + indent("hintColor = #{hint_color},", depth + 1)
  124. end
  125. 28 if json_data['cornerRadius']
  126. 1 code += "\n" + indent("cornerRadius = #{json_data['cornerRadius']},", depth + 1)
  127. end
  128. # Add cancel button background color if specified
  129. 28 if json_data['cancelButtonBackgroundColor']
  130. 1 cancel_bg = Helpers::ResourceResolver.process_color(json_data['cancelButtonBackgroundColor'], required_imports)
  131. 1 code += "\n" + indent("cancelButtonBackgroundColor = #{cancel_bg},", depth + 1)
  132. end
  133. # Add cancel button text color if specified
  134. 28 if json_data['cancelButtonTextColor']
  135. 1 cancel_text = Helpers::ResourceResolver.process_color(json_data['cancelButtonTextColor'], required_imports)
  136. 1 code += "\n" + indent("cancelButtonTextColor = #{cancel_text},", depth + 1)
  137. end
  138. # Build modifiers
  139. 28 modifiers = []
  140. # Ensure fillMaxWidth if width is not specified for date pickers
  141. 28 if is_date_picker && !json_data['width']
  142. 9 modifiers << ".fillMaxWidth()"
  143. end
  144. 28 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  145. 28 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  146. 28 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  147. 28 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  148. 28 if modifiers.any? && !modifiers.include?('SKIP_RENDER')
  149. 9 code += Helpers::ModifierBuilder.format(modifiers, depth)
  150. end
  151. 28 code += "\n" + indent(")", depth)
  152. 28 code
  153. end
  154. 1 private
  155. 1 def self.indent(text, level)
  156. 155 return text if level == 0
  157. 98 spaces = ' ' * level
  158. 98 text.split("\n").map { |line|
  159. 98 line.empty? ? line : spaces + line
  160. }.join("\n")
  161. end
  162. end
  163. end
  164. end
  165. end

lib/compose/components/slider_component.rb

100.0% lines covered

68 relevant lines. 68 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class SliderComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Slider uses 'value' or 'bind' for binding
  10. 23 value = if json_data['value']
  11. 2 if json_data['value'].is_a?(String) && json_data['value'].match(/@\{([^}]+)\}/)
  12. 1 variable = $1
  13. 1 "data.#{variable}.toFloat()"
  14. else
  15. # Direct value
  16. 1 "#{json_data['value']}f"
  17. end
  18. 21 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  19. 1 variable = $1
  20. 1 "data.#{variable}.toFloat()"
  21. else
  22. 20 '0f'
  23. end
  24. # Support both naming conventions for min/max
  25. 23 min_value = json_data['minimumValue'] || json_data['min'] || 0
  26. 23 max_value = json_data['maximumValue'] || json_data['max'] || 100
  27. 23 code = indent("Slider(", depth)
  28. 23 code += "\n" + indent("value = #{value},", depth + 1)
  29. # onValueChange handler
  30. 23 binding_variable = nil
  31. 23 if json_data['value'] && json_data['value'].is_a?(String) && json_data['value'].match(/@\{([^}]+)\}/)
  32. 1 binding_variable = $1
  33. 22 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  34. 1 binding_variable = $1
  35. end
  36. 23 if json_data['onValueChange']
  37. # Use custom handler if specified
  38. 1 code += "\n" + indent("onValueChange = { viewModel.#{json_data['onValueChange']}(it) },", depth + 1)
  39. 22 elsif binding_variable
  40. # Update the bound variable - check if it's Int or Double/Float based on the data type
  41. 2 code += "\n" + indent("onValueChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue.toDouble())) },", depth + 1)
  42. else
  43. 20 code += "\n" + indent("onValueChange = { },", depth + 1)
  44. end
  45. # Value range
  46. 23 code += "\n" + indent("valueRange = #{min_value}f..#{max_value}f,", depth + 1)
  47. # Steps
  48. 23 if json_data['step'] && json_data['step'] > 0
  49. 1 steps = ((max_value - min_value) / json_data['step'].to_f).to_i - 1
  50. 1 code += "\n" + indent("steps = #{steps},", depth + 1) if steps > 0
  51. end
  52. # Build modifiers
  53. 23 modifiers = []
  54. 23 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  55. 23 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  56. 23 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  57. 23 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  58. # Slider colors
  59. 23 if json_data['minimumTrackTintColor'] || json_data['maximumTrackTintColor'] || json_data['thumbTintColor']
  60. 4 required_imports&.add(:slider_colors)
  61. 4 colors_params = []
  62. 4 if json_data['thumbTintColor']
  63. 2 thumbcolor_resolved = Helpers::ResourceResolver.process_color(json_data['thumbTintColor'], required_imports)
  64. 2 colors_params << "thumbColor = #{thumbcolor_resolved}"
  65. end
  66. 4 if json_data['minimumTrackTintColor']
  67. 2 activetrackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['minimumTrackTintColor'], required_imports)
  68. 2 colors_params << "activeTrackColor = #{activetrackcolor_resolved}"
  69. end
  70. 4 if json_data['maximumTrackTintColor']
  71. 2 inactivetrackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['maximumTrackTintColor'], required_imports)
  72. 2 colors_params << "inactiveTrackColor = #{inactivetrackcolor_resolved}"
  73. end
  74. 4 if colors_params.any?
  75. 4 code += ",\n" + indent("colors = SliderDefaults.colors(", depth + 1)
  76. 10 code += "\n" + colors_params.map { |param| indent(param, depth + 2) }.join(",\n")
  77. 4 code += "\n" + indent(")", depth + 1)
  78. end
  79. end
  80. # Handle enabled attribute
  81. 23 if json_data.key?('enabled')
  82. 3 if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
  83. 1 variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
  84. 1 code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
  85. else
  86. 2 code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
  87. end
  88. end
  89. 23 code += "\n" + indent(")", depth)
  90. 23 code
  91. end
  92. 1 private
  93. 1 def self.indent(text, level)
  94. 136 return text if level == 0
  95. 89 spaces = ' ' * level
  96. 89 text.split("\n").map { |line|
  97. 90 line.empty? ? line : spaces + line
  98. }.join("\n")
  99. end
  100. end
  101. end
  102. end
  103. end

lib/compose/components/switch_component.rb

72.13% lines covered

61 relevant lines. 44 lines covered and 17 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class SwitchComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Switch uses 'isOn' or 'bind' for binding
  10. 7 checked = if json_data['isOn']
  11. if json_data['isOn'].is_a?(String) && json_data['isOn'].match(/@\{([^}]+)\}/)
  12. variable = $1
  13. "data.#{variable}"
  14. else
  15. # Direct boolean value
  16. json_data['isOn'].to_s
  17. end
  18. 7 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  19. variable = $1
  20. "data.#{variable}"
  21. else
  22. 7 'false'
  23. end
  24. 7 code = indent("Switch(", depth)
  25. 7 code += "\n" + indent("checked = #{checked},", depth + 1)
  26. # onCheckedChange handler
  27. 7 binding_variable = nil
  28. 7 if json_data['isOn'] && json_data['isOn'].is_a?(String) && json_data['isOn'].match(/@\{([^}]+)\}/)
  29. binding_variable = $1
  30. 7 elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  31. binding_variable = $1
  32. end
  33. 7 if json_data['onValueChange']
  34. # Use custom handler if specified
  35. code += "\n" + indent("onCheckedChange = { viewModel.#{json_data['onValueChange']}(it) },", depth + 1)
  36. 7 elsif binding_variable
  37. # Update the bound variable
  38. code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue)) },", depth + 1)
  39. else
  40. 7 code += "\n" + indent("onCheckedChange = { },", depth + 1)
  41. end
  42. # Build modifiers
  43. 7 modifiers = []
  44. 7 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  45. 7 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  46. 7 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  47. # Add weight modifier if in Row or Column
  48. 7 if parent_type == 'Row' || parent_type == 'Column'
  49. modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  50. end
  51. 7 code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
  52. # Switch colors
  53. 7 if json_data['onTintColor'] || json_data['thumbTintColor']
  54. 1 required_imports&.add(:switch_colors)
  55. 1 colors_params = []
  56. 1 if json_data['onTintColor']
  57. 1 checkedtrackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['onTintColor'], required_imports)
  58. 1 colors_params << "checkedTrackColor = #{checkedtrackcolor_resolved}"
  59. end
  60. 1 if json_data['thumbTintColor']
  61. checkedthumbcolor_resolved = Helpers::ResourceResolver.process_color(json_data['thumbTintColor'], required_imports)
  62. colors_params << "checkedThumbColor = #{checkedthumbcolor_resolved}"
  63. end
  64. 1 if colors_params.any?
  65. 1 code += ",\n" + indent("colors = SwitchDefaults.colors(", depth + 1)
  66. 2 code += "\n" + colors_params.map { |param| indent(param, depth + 2) }.join(",\n")
  67. 1 code += "\n" + indent(")", depth + 1)
  68. end
  69. end
  70. # Handle enabled attribute
  71. 7 if json_data.key?('enabled')
  72. if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
  73. variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
  74. code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
  75. else
  76. code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
  77. end
  78. end
  79. 7 code += "\n" + indent(")", depth)
  80. 7 code
  81. end
  82. 1 private
  83. 1 def self.indent(text, level)
  84. 31 return text if level == 0
  85. 17 spaces = ' ' * level
  86. 17 text.split("\n").map { |line|
  87. 17 line.empty? ? line : spaces + line
  88. }.join("\n")
  89. end
  90. end
  91. end
  92. end
  93. end

lib/compose/components/table_component.rb

100.0% lines covered

108 relevant lines. 108 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 module KjuiTools
  4. 1 module Compose
  5. 1 module Components
  6. 1 class TableComponent
  7. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  8. 21 required_imports&.add(:lazy_column)
  9. # Table uses data binding for items
  10. 21 items = if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
  11. 1 variable = $1
  12. 1 "data.#{variable}"
  13. 20 elsif json_data['items'] && json_data['items'].match(/@\{([^}]+)\}/)
  14. 1 variable = $1
  15. 1 "data.#{variable}"
  16. else
  17. 19 'emptyList()'
  18. end
  19. 21 code = indent("LazyColumn(", depth)
  20. # Content padding
  21. 21 if json_data['contentPadding']
  22. 2 padding = json_data['contentPadding']
  23. 2 if padding.is_a?(Array) && padding.length == 4
  24. 1 code += "\n" + indent("contentPadding = PaddingValues(top = #{padding[0]}.dp, end = #{padding[1]}.dp, bottom = #{padding[2]}.dp, start = #{padding[3]}.dp),", depth + 1)
  25. 1 elsif padding.is_a?(Numeric)
  26. 1 code += "\n" + indent("contentPadding = PaddingValues(#{padding}.dp),", depth + 1)
  27. end
  28. end
  29. # Vertical arrangement (spacing between rows)
  30. 21 if json_data['rowSpacing'] || json_data['spacing']
  31. 2 required_imports&.add(:arrangement)
  32. 2 spacing = json_data['rowSpacing'] || json_data['spacing'] || 0
  33. 2 code += "\n" + indent("verticalArrangement = Arrangement.spacedBy(#{spacing}.dp),", depth + 1)
  34. end
  35. # Build modifiers
  36. 21 modifiers = []
  37. 21 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  38. 21 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  39. 21 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  40. 21 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  41. 21 code += Helpers::ModifierBuilder.format(modifiers, depth)
  42. 21 code += "\n" + indent(") {", depth)
  43. # Table header if specified
  44. 21 if json_data['header']
  45. 4 code += "\n" + indent("item {", depth + 1)
  46. 4 code += generate_header_row(json_data['header'], depth + 2, required_imports)
  47. 4 code += "\n" + indent("}", depth + 1)
  48. # Divider after header
  49. 4 if json_data['separatorStyle'] != 'none'
  50. 3 code += "\n" + indent("item {", depth + 1)
  51. 3 code += "\n" + indent("Divider(", depth + 2)
  52. 3 code += "\n" + indent("color = Color.LightGray,", depth + 3)
  53. 3 code += "\n" + indent("thickness = 1.dp", depth + 3)
  54. 3 code += "\n" + indent(")", depth + 2)
  55. 3 code += "\n" + indent("}", depth + 1)
  56. end
  57. end
  58. # Table rows
  59. 21 code += "\n" + indent("items(#{items}) { item ->", depth + 1)
  60. # Row content
  61. 21 if json_data['cell']
  62. # Custom cell template
  63. 1 cell_content = generate_table_cell(json_data['cell'], depth + 2, required_imports)
  64. 1 code += "\n" + cell_content
  65. else
  66. # Default row
  67. 20 code += generate_default_row(json_data, depth + 2, required_imports)
  68. end
  69. # Separator between rows
  70. 21 if json_data['separatorStyle'] != 'none'
  71. 19 code += "\n" + indent("Divider(", depth + 2)
  72. # Separator inset
  73. 19 if json_data['separatorInset']
  74. 2 inset = json_data['separatorInset']
  75. 2 if inset.is_a?(Hash)
  76. 2 start_padding = inset['left'] || inset['start'] || 0
  77. 2 code += "\n" + indent("modifier = Modifier.padding(start = #{start_padding}.dp),", depth + 3)
  78. end
  79. end
  80. 19 code += "\n" + indent("color = Color.LightGray,", depth + 3)
  81. 19 code += "\n" + indent("thickness = 0.5.dp", depth + 3)
  82. 19 code += "\n" + indent(")", depth + 2)
  83. end
  84. 21 code += "\n" + indent("}", depth + 1)
  85. 21 code += "\n" + indent("}", depth)
  86. 21 code
  87. end
  88. 1 private
  89. 1 def self.generate_header_row(header_data, depth, required_imports)
  90. 4 code = indent("Row(", depth)
  91. 4 code += "\n" + indent("modifier = Modifier", depth + 1)
  92. 4 code += "\n" + indent(" .fillMaxWidth()", depth + 1)
  93. 4 code += "\n" + indent(" .padding(horizontal = 16.dp, vertical = 12.dp),", depth + 1)
  94. 4 code += "\n" + indent("horizontalArrangement = Arrangement.SpaceBetween", depth + 1)
  95. 4 code += "\n" + indent(") {", depth)
  96. 4 if header_data.is_a?(Array)
  97. 3 header_data.each do |column|
  98. 5 code += "\n" + indent("Text(", depth + 1)
  99. 5 code += "\n" + indent("text = \"#{column}\",", depth + 2)
  100. 5 code += "\n" + indent("fontWeight = FontWeight.Bold,", depth + 2)
  101. 5 code += "\n" + indent("modifier = Modifier.weight(1f)", depth + 2)
  102. 5 code += "\n" + indent(")", depth + 1)
  103. end
  104. else
  105. 1 code += "\n" + indent("Text(text = \"Header\", fontWeight = FontWeight.Bold)", depth + 1)
  106. end
  107. 4 code += "\n" + indent("}", depth)
  108. 4 code
  109. end
  110. 1 def self.generate_table_cell(cell_data, depth, required_imports)
  111. 1 code = indent("Row(", depth)
  112. 1 code += "\n" + indent("modifier = Modifier", depth + 1)
  113. 1 code += "\n" + indent(" .fillMaxWidth()", depth + 1)
  114. 1 code += "\n" + indent(" .clickable { /* Handle row click */ }", depth + 1)
  115. 1 code += "\n" + indent(" .padding(horizontal = 16.dp, vertical = 12.dp)", depth + 1)
  116. 1 code += "\n" + indent(") {", depth)
  117. # Cell content based on template
  118. 1 code += "\n" + indent("// Custom cell rendering", depth + 1)
  119. 1 code += "\n" + indent("Text(text = item.toString())", depth + 1)
  120. 1 code += "\n" + indent("}", depth)
  121. 1 code
  122. end
  123. 1 def self.generate_default_row(json_data, depth, required_imports)
  124. 20 row_height = json_data['rowHeight'] || 60
  125. 20 code = "\n" + indent("Row(", depth)
  126. 20 code += "\n" + indent("modifier = Modifier", depth + 1)
  127. 20 code += "\n" + indent(" .fillMaxWidth()", depth + 1)
  128. 20 code += "\n" + indent(" .height(#{row_height}.dp)", depth + 1)
  129. 20 code += "\n" + indent(" .clickable { /* Handle row click */ }", depth + 1)
  130. 20 code += "\n" + indent(" .padding(horizontal = 16.dp),", depth + 1)
  131. 20 code += "\n" + indent("verticalAlignment = Alignment.CenterVertically", depth + 1)
  132. 20 code += "\n" + indent(") {", depth)
  133. 20 code += "\n" + indent("Text(text = item.toString())", depth + 1)
  134. 20 code += "\n" + indent("}", depth)
  135. 20 code
  136. end
  137. 1 def self.indent(text, level)
  138. 479 return text if level == 0
  139. 415 spaces = ' ' * level
  140. 415 text.split("\n").map { |line|
  141. 417 line.empty? ? line : spaces + line
  142. }.join("\n")
  143. end
  144. end
  145. end
  146. end
  147. end

lib/compose/components/tabview_component.rb

91.23% lines covered

57 relevant lines. 52 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class TabviewComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # TabView maps to TabRow with Tab items in Compose
  10. 12 required_imports&.add(:tab_row)
  11. 12 required_imports&.add(:remember_state)
  12. # Generate state variable for selected tab
  13. 12 state_var = "selectedTab_#{Time.now.to_i}_#{rand(1000)}"
  14. 12 code = indent("// Tab view with content", depth)
  15. 12 code += "\n" + indent("var #{state_var} by remember { mutableStateOf(0) }", depth)
  16. 12 code += "\n\n" + indent("Column(", depth)
  17. # Column modifiers
  18. 12 modifiers = []
  19. 12 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  20. 12 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  21. 12 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  22. 12 code += Helpers::ModifierBuilder.format(modifiers, depth)
  23. 12 code += "\n" + indent(") {", depth)
  24. # TabRow
  25. 12 code += "\n" + indent("TabRow(", depth + 1)
  26. 12 code += "\n" + indent("selectedTabIndex = #{state_var},", depth + 2)
  27. # TabRow modifiers
  28. 12 tab_modifiers = []
  29. 12 if json_data['backgroundColor']
  30. tab_modifiers << ".background(Helpers::ResourceResolver.process_color('#{json_data['backgroundColor']}', required_imports))"
  31. end
  32. 12 if tab_modifiers.any?
  33. code += "\n" + indent("modifier = Modifier", depth + 2)
  34. tab_modifiers.each do |mod|
  35. code += "\n" + indent(mod, depth + 3)
  36. end
  37. code += ","
  38. end
  39. 12 code += "\n" + indent(") {", depth + 1)
  40. # Generate tabs from items
  41. 12 if json_data['items'] && json_data['items'].is_a?(Array)
  42. 12 json_data['items'].each_with_index do |item, index|
  43. 16 title = item['title'] || "Tab #{index + 1}"
  44. 16 code += "\n" + indent("Tab(", depth + 2)
  45. 16 code += "\n" + indent("selected = #{state_var} == #{index},", depth + 3)
  46. 16 code += "\n" + indent("onClick = { #{state_var} = #{index} },", depth + 3)
  47. 16 code += "\n" + indent("text = { Text(\"#{title}\") }", depth + 3)
  48. 16 code += "\n" + indent(")", depth + 2)
  49. end
  50. end
  51. 12 code += "\n" + indent("}", depth + 1)
  52. # Tab content using when expression
  53. 12 if json_data['items'] && json_data['items'].is_a?(Array)
  54. 12 code += "\n\n" + indent("// Tab content", depth + 1)
  55. 12 code += "\n" + indent("when (#{state_var}) {", depth + 1)
  56. 12 json_data['items'].each_with_index do |item, index|
  57. 16 code += "\n" + indent("#{index} -> {", depth + 2)
  58. # Content for each tab
  59. 16 if item['child']
  60. 1 code += "\n" + indent("// Content for tab #{index}", depth + 3)
  61. # Note: Actual child content would be generated by the parent
  62. else
  63. 15 code += "\n" + indent("Text(\"Content for #{item['title'] || "Tab #{index + 1}"}\")", depth + 3)
  64. end
  65. 16 code += "\n" + indent("}", depth + 2)
  66. end
  67. 12 code += "\n" + indent("}", depth + 1)
  68. end
  69. 12 code += "\n" + indent("}", depth)
  70. 12 code
  71. end
  72. 1 private
  73. 1 def self.indent(text, level)
  74. 275 return text if level == 0
  75. 214 spaces = ' ' * level
  76. 214 text.split("\n").map { |line|
  77. 214 line.empty? ? line : spaces + line
  78. }.join("\n")
  79. end
  80. end
  81. end
  82. end
  83. end

lib/compose/components/text_component.rb

71.92% lines covered

292 relevant lines. 210 lines covered and 82 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/visibility_helper'
  4. 1 require_relative '../helpers/resource_resolver'
  5. 1 module KjuiTools
  6. 1 module Compose
  7. 1 module Components
  8. 1 class TextComponent
  9. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  10. # Check if component should be skipped entirely (static gone/hidden)
  11. 60 return "" if Helpers::VisibilityHelper.should_skip_render?(json_data)
  12. # Check if we need to use PartialAttributesText for partial attributes
  13. 59 if json_data['partialAttributes'] && json_data['partialAttributes'].any?
  14. 8 return generate_with_partial_attributes_component(json_data, depth, required_imports, parent_type)
  15. end
  16. # Check if we need to use PartialAttributesText for linkable attribute
  17. 51 if json_data['linkable']
  18. 9 return generate_with_partial_attributes_for_linkable(json_data, depth, required_imports, parent_type)
  19. end
  20. 42 text = Helpers::ResourceResolver.process_text(json_data['text'] || '', required_imports)
  21. 42 component_code = indent("Text(", depth)
  22. 42 component_code += "\n" + indent("text = #{text},", depth + 1)
  23. # Font size
  24. 42 if json_data['fontSize']
  25. 2 component_code += "\n" + indent("fontSize = #{json_data['fontSize']}.sp,", depth + 1)
  26. end
  27. # Font color (official attribute)
  28. 42 if json_data['fontColor']
  29. 1 color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  30. 1 component_code += "\n" + indent("color = #{color_value},", depth + 1) if color_value
  31. end
  32. # Font weight - handle both 'font' and 'fontWeight' attributes
  33. 42 if json_data['font'] == 'bold' || json_data['fontWeight'] == 'bold'
  34. 3 component_code += "\n" + indent("fontWeight = FontWeight.Bold,", depth + 1)
  35. 39 elsif json_data['fontWeight']
  36. # Map font weight values to Compose FontWeight constants
  37. 6 weight_mapping = {
  38. 'thin' => 'Thin',
  39. 'extralight' => 'ExtraLight',
  40. 'light' => 'Light',
  41. 'normal' => 'Normal',
  42. 'medium' => 'Medium',
  43. 'semibold' => 'SemiBold', # Note: SemiBold with capital B
  44. 'bold' => 'Bold',
  45. 'extrabold' => 'ExtraBold',
  46. 'black' => 'Black'
  47. }
  48. 6 weight = weight_mapping[json_data['fontWeight'].downcase] || json_data['fontWeight'].capitalize
  49. 6 component_code += "\n" + indent("fontWeight = FontWeight.#{weight},", depth + 1)
  50. end
  51. # Text decoration (underline, strikethrough)
  52. 42 text_decorations = []
  53. 42 if json_data['underline']
  54. 2 required_imports&.add(:text_decoration)
  55. 2 text_decorations << "TextDecoration.Underline"
  56. end
  57. 42 if json_data['strikethrough']
  58. 2 required_imports&.add(:text_decoration)
  59. 2 text_decorations << "TextDecoration.LineThrough"
  60. end
  61. 42 if text_decorations.any?
  62. 3 if text_decorations.length > 1
  63. 1 component_code += "\n" + indent("textDecoration = TextDecoration.combine(listOf(#{text_decorations.join(', ')})),", depth + 1)
  64. else
  65. 2 component_code += "\n" + indent("textDecoration = #{text_decorations.first},", depth + 1)
  66. end
  67. end
  68. # Text shadow and line height
  69. 42 style_parts = []
  70. 42 if json_data['textShadow']
  71. 1 required_imports&.add(:shadow_style)
  72. 1 style_parts << "shadow = Shadow(color = Color.Black, offset = Offset(2f, 2f), blurRadius = 4f)"
  73. end
  74. 42 if json_data['lineHeightMultiple']
  75. 1 required_imports&.add(:text_style)
  76. # Line height multiplier - apply to font size
  77. 1 line_height = json_data['fontSize'] ? json_data['fontSize'].to_f * json_data['lineHeightMultiple'].to_f : 14.0 * json_data['lineHeightMultiple'].to_f
  78. 1 style_parts << "lineHeight = #{line_height}.sp"
  79. end
  80. 42 if style_parts.any?
  81. 2 required_imports&.add(:text_style)
  82. 2 component_code += "\n" + indent("style = TextStyle(#{style_parts.join(', ')}),", depth + 1)
  83. end
  84. # Build modifiers
  85. 42 modifiers = []
  86. # Get visibility info (but don't add to modifiers, will be handled by wrapper)
  87. 42 visibility_result = Helpers::ModifierBuilder.build_visibility(json_data, required_imports)
  88. 42 modifiers.concat(visibility_result[:modifiers]) if visibility_result[:modifiers].any?
  89. 42 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  90. # Add weight modifier if in Row or Column
  91. 42 if parent_type == 'Row' || parent_type == 'Column'
  92. 2 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  93. end
  94. # 1. Add size first (total size including padding)
  95. 42 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  96. # 2. Add margins (outside spacing)
  97. 42 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  98. # 3. Add shadow before background
  99. 42 modifiers.concat(Helpers::ModifierBuilder.build_shadow(json_data, required_imports))
  100. # 4. Add background before padding (so padding creates space inside the background)
  101. 42 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  102. # 5. Handle edgeInset for text-specific padding (inside spacing) - applied last
  103. 42 if json_data['edgeInset']
  104. 2 insets = json_data['edgeInset']
  105. 2 if insets.is_a?(Array) && insets.length == 4
  106. 1 modifiers << ".padding(top = #{insets[0]}.dp, end = #{insets[1]}.dp, bottom = #{insets[2]}.dp, start = #{insets[3]}.dp)"
  107. 1 elsif insets.is_a?(Numeric)
  108. 1 modifiers << ".padding(#{insets}.dp)"
  109. end
  110. else
  111. 40 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  112. end
  113. # Format modifiers
  114. 42 if modifiers.any?
  115. 8 component_code += Helpers::ModifierBuilder.format(modifiers, depth)
  116. else
  117. 34 component_code += "\n" + indent("modifier = Modifier", depth + 1)
  118. end
  119. # Text alignment
  120. 42 if json_data['textAlign']
  121. 3 required_imports&.add(:text_align)
  122. 3 case json_data['textAlign'].downcase
  123. when 'center'
  124. 1 component_code += ",\n" + indent("textAlign = TextAlign.Center", depth + 1)
  125. when 'right'
  126. 1 component_code += ",\n" + indent("textAlign = TextAlign.End", depth + 1)
  127. when 'left'
  128. 1 component_code += ",\n" + indent("textAlign = TextAlign.Start", depth + 1)
  129. end
  130. 39 elsif json_data['centerHorizontal']
  131. 1 required_imports&.add(:text_align)
  132. 1 component_code += ",\n" + indent("textAlign = TextAlign.Center", depth + 1)
  133. end
  134. # Lines (maxLines)
  135. 42 if json_data['lines']
  136. 2 if json_data['lines'] == 0
  137. 1 component_code += ",\n" + indent("maxLines = Int.MAX_VALUE", depth + 1)
  138. else
  139. 1 component_code += ",\n" + indent("maxLines = #{json_data['lines']}", depth + 1)
  140. end
  141. end
  142. # Minimum scale factor (auto-shrink text)
  143. # In Compose, this is achieved with softWrap=false and overflow=Visible to allow text to scale
  144. 42 if json_data['minimumScaleFactor']
  145. # Note: Compose doesn't have direct equivalent, but we can use single line with ellipsis
  146. # or recommend using a custom composable. For now, we'll add a comment
  147. 1 component_code += ",\n" + indent("// minimumScaleFactor: #{json_data['minimumScaleFactor']} - Consider using AutoSizeText library", depth + 1)
  148. 1 component_code += ",\n" + indent("maxLines = 1", depth + 1)
  149. 1 required_imports&.add(:text_overflow)
  150. 1 component_code += ",\n" + indent("overflow = TextOverflow.Ellipsis", depth + 1)
  151. end
  152. # Line break mode (overflow)
  153. 42 if json_data['lineBreakMode']
  154. 3 required_imports&.add(:text_overflow)
  155. 3 case json_data['lineBreakMode'].downcase
  156. when 'clip'
  157. 1 component_code += ",\n" + indent("overflow = TextOverflow.Clip", depth + 1)
  158. when 'tail', 'word'
  159. 2 component_code += ",\n" + indent("overflow = TextOverflow.Ellipsis", depth + 1)
  160. end
  161. end
  162. 42 component_code += "\n" + indent(")", depth)
  163. # Wrap with VisibilityWrapper if needed
  164. 42 Helpers::VisibilityHelper.wrap_with_visibility(json_data, component_code, depth, required_imports)
  165. end
  166. 1 private
  167. 1 def self.generate_with_partial_attributes_for_linkable(json_data, depth, required_imports, parent_type)
  168. 9 required_imports&.add(:partial_attributes_text)
  169. 9 text = json_data['text'] || ''
  170. 9 code = indent("PartialAttributesText(", depth)
  171. 9 code += "\n" + indent("text = \"#{escape_string(text)}\",", depth + 1)
  172. 9 code += "\n" + indent("linkable = true,", depth + 1)
  173. # Build style
  174. 9 style_parts = []
  175. 9 if json_data['fontSize']
  176. 1 style_parts << "fontSize = #{json_data['fontSize']}.sp"
  177. end
  178. 9 if json_data['fontColor']
  179. 1 color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  180. 1 style_parts << "color = #{color_value}" if color_value
  181. end
  182. 9 if json_data['font'] == 'bold' || json_data['fontWeight'] == 'bold'
  183. style_parts << "fontWeight = FontWeight.Bold"
  184. 9 elsif json_data['fontWeight']
  185. 1 weight_mapping = {
  186. 'thin' => 'Thin',
  187. 'extralight' => 'ExtraLight',
  188. 'light' => 'Light',
  189. 'normal' => 'Normal',
  190. 'medium' => 'Medium',
  191. 'semibold' => 'SemiBold',
  192. 'bold' => 'Bold',
  193. 'extrabold' => 'ExtraBold',
  194. 'black' => 'Black'
  195. }
  196. 1 weight = weight_mapping[json_data['fontWeight'].downcase] || json_data['fontWeight'].capitalize
  197. 1 style_parts << "fontWeight = FontWeight.#{weight}"
  198. end
  199. 9 if json_data['textAlign']
  200. 3 required_imports&.add(:text_align)
  201. 3 case json_data['textAlign'].downcase
  202. when 'center'
  203. 1 style_parts << "textAlign = TextAlign.Center"
  204. when 'right'
  205. 1 style_parts << "textAlign = TextAlign.End"
  206. when 'left'
  207. 1 style_parts << "textAlign = TextAlign.Start"
  208. end
  209. end
  210. 9 if style_parts.any?
  211. 6 required_imports&.add(:text_style)
  212. 6 code += "\n" + indent("style = TextStyle(#{style_parts.join(', ')}),", depth + 1)
  213. end
  214. # Build modifiers
  215. 9 modifiers = []
  216. 9 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  217. 9 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  218. # Handle edgeInset for text-specific padding
  219. 9 if json_data['edgeInset']
  220. 2 insets = json_data['edgeInset']
  221. 2 if insets.is_a?(Array) && insets.length == 4
  222. 1 modifiers << ".padding(top = #{insets[0]}.dp, end = #{insets[1]}.dp, bottom = #{insets[2]}.dp, start = #{insets[3]}.dp)"
  223. 1 elsif insets.is_a?(Numeric)
  224. 1 modifiers << ".padding(#{insets}.dp)"
  225. end
  226. else
  227. 7 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  228. end
  229. # Add background
  230. 9 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  231. 9 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  232. 9 if modifiers.any?
  233. 2 code += Helpers::ModifierBuilder.format(modifiers, depth)
  234. else
  235. 7 code += "\n" + indent("modifier = Modifier", depth + 1)
  236. end
  237. 9 code += "\n" + indent(")", depth)
  238. # Wrap with VisibilityWrapper if needed
  239. 9 Helpers::VisibilityHelper.wrap_with_visibility(json_data, code, depth, required_imports)
  240. end
  241. 1 def self.generate_with_partial_attributes_component(json_data, depth, required_imports, parent_type)
  242. 8 required_imports&.add(:partial_attributes_text)
  243. 8 text = json_data['text'] || ''
  244. 8 partial_attrs = json_data['partialAttributes']
  245. 8 code = indent("PartialAttributesText(", depth)
  246. 8 code += "\n" + indent("text = \"#{escape_string(text)}\",", depth + 1)
  247. # Build partial attributes list
  248. 8 code += "\n" + indent("partialAttributes = listOf(", depth + 1)
  249. 8 partial_attrs.each_with_index do |attr, index|
  250. 9 code += "\n" + indent("PartialAttribute.fromJsonRange(", depth + 2)
  251. # Handle range - can be array or string
  252. 9 range = attr['range']
  253. 9 if range.is_a?(Array)
  254. 8 code += "\n" + indent("range = listOf(#{range.join(', ')}),", depth + 3)
  255. 1 elsif range.is_a?(String)
  256. 1 code += "\n" + indent("range = \"#{escape_string(range)}\",", depth + 3)
  257. end
  258. 9 code += "\n" + indent("text = \"#{escape_string(text)}\",", depth + 3)
  259. # Add optional attributes
  260. 9 if attr['fontColor']
  261. 4 code += "\n" + indent("fontColor = \"#{attr['fontColor']}\",", depth + 3)
  262. end
  263. 9 if attr['fontSize']
  264. 1 code += "\n" + indent("fontSize = #{attr['fontSize']},", depth + 3)
  265. end
  266. 9 if attr['fontWeight']
  267. 1 code += "\n" + indent("fontWeight = \"#{attr['fontWeight']}\",", depth + 3)
  268. end
  269. 9 if attr['background']
  270. 1 code += "\n" + indent("background = \"#{attr['background']}\",", depth + 3)
  271. end
  272. 9 if attr['underline']
  273. 1 code += "\n" + indent("underline = #{attr['underline']},", depth + 3)
  274. end
  275. 9 if attr['strikethrough']
  276. 1 code += "\n" + indent("strikethrough = #{attr['strikethrough']},", depth + 3)
  277. end
  278. 9 if attr['onclick']
  279. 1 code += "\n" + indent("onClick = { viewModel.#{attr['onclick']}() }", depth + 3)
  280. else
  281. 8 code += "\n" + indent("onClick = null", depth + 3)
  282. end
  283. 9 code += "\n" + indent(")!!", depth + 2) # !! because fromJsonRange returns nullable
  284. 9 code += "," if index < partial_attrs.length - 1
  285. end
  286. 8 code += "\n" + indent("),", depth + 1)
  287. # Build modifiers
  288. 8 modifiers = []
  289. 8 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  290. 8 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  291. 8 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  292. 8 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  293. 8 if modifiers.any?
  294. code += Helpers::ModifierBuilder.format(modifiers, depth)
  295. else
  296. 8 code += "\n" + indent("modifier = Modifier", depth + 1)
  297. end
  298. # Add style
  299. 8 style_parts = []
  300. 8 style_parts << "fontSize = #{json_data['fontSize']}.sp" if json_data['fontSize']
  301. 8 if json_data['fontColor']
  302. color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  303. style_parts << "color = #{color_value}" if color_value
  304. end
  305. 8 if json_data['textAlign']
  306. required_imports&.add(:text_align)
  307. case json_data['textAlign'].downcase
  308. when 'center'
  309. style_parts << "textAlign = TextAlign.Center"
  310. when 'right'
  311. style_parts << "textAlign = TextAlign.End"
  312. when 'left'
  313. style_parts << "textAlign = TextAlign.Start"
  314. end
  315. end
  316. 8 if style_parts.any?
  317. required_imports&.add(:text_style)
  318. code += ",\n" + indent("style = TextStyle(#{style_parts.join(', ')})", depth + 1)
  319. end
  320. 8 code += "\n" + indent(")", depth)
  321. # Wrap with VisibilityWrapper if needed
  322. 8 Helpers::VisibilityHelper.wrap_with_visibility(json_data, code, depth, required_imports)
  323. end
  324. 1 def self.generate_with_partial_attributes(json_data, depth, required_imports, parent_type)
  325. required_imports&.add(:annotated_string)
  326. required_imports&.add(:clickable_text)
  327. required_imports&.add(:remember_state)
  328. text = json_data['text'] || ''
  329. partial_attrs = json_data['partialAttributes']
  330. # Build AnnotatedString as a variable first
  331. code = indent("val annotatedText = buildAnnotatedString {", depth)
  332. code += "\n" + indent("append(\"#{escape_string(text)}\")", depth + 1)
  333. # Apply partial attributes
  334. partial_attrs.each do |attr|
  335. range = attr['range']
  336. next unless range && range.is_a?(Array) && range.length == 2
  337. start_idx = range[0]
  338. end_idx = range[1]
  339. # Build SpanStyle for this range
  340. span_styles = []
  341. if attr['fontColor']
  342. color_resolved = Helpers::ResourceResolver.process_color(attr['fontColor'], required_imports)
  343. span_styles << "color = #{color_resolved}"
  344. end
  345. if attr['fontSize']
  346. span_styles << "fontSize = #{attr['fontSize']}.sp"
  347. end
  348. if attr['fontWeight']
  349. weight_mapping = {
  350. 'bold' => 'Bold',
  351. 'semibold' => 'SemiBold',
  352. 'medium' => 'Medium',
  353. 'light' => 'Light'
  354. }
  355. weight = weight_mapping[attr['fontWeight'].downcase] || 'Normal'
  356. span_styles << "fontWeight = FontWeight.#{weight}"
  357. end
  358. if attr['background']
  359. background_resolved = Helpers::ResourceResolver.process_color(attr['background'], required_imports)
  360. span_styles << "background = #{background_resolved}"
  361. end
  362. if attr['underline']
  363. required_imports&.add(:text_decoration)
  364. span_styles << "textDecoration = TextDecoration.Underline"
  365. end
  366. if attr['strikethrough']
  367. required_imports&.add(:text_decoration)
  368. span_styles << "textDecoration = TextDecoration.LineThrough"
  369. end
  370. if span_styles.any?
  371. code += "\n" + indent("addStyle(", depth + 1)
  372. code += "\n" + indent("style = SpanStyle(#{span_styles.join(', ')}),", depth + 2)
  373. code += "\n" + indent("start = #{start_idx},", depth + 2)
  374. code += "\n" + indent("end = #{end_idx}", depth + 2)
  375. code += "\n" + indent(")", depth + 1)
  376. end
  377. # Add clickable annotation if onclick is specified
  378. if attr['onclick']
  379. code += "\n" + indent("addStringAnnotation(", depth + 1)
  380. code += "\n" + indent("tag = \"CLICKABLE\",", depth + 2)
  381. code += "\n" + indent("annotation = \"#{attr['onclick']}\",", depth + 2)
  382. code += "\n" + indent("start = #{start_idx},", depth + 2)
  383. code += "\n" + indent("end = #{end_idx}", depth + 2)
  384. code += "\n" + indent(")", depth + 1)
  385. end
  386. end
  387. code += "\n" + indent("}", depth)
  388. code += "\n"
  389. # Now use ClickableText with the annotatedString
  390. code += indent("ClickableText(", depth)
  391. code += "\n" + indent("text = annotatedText,", depth + 1)
  392. # Add onClick handler for clickable ranges
  393. if partial_attrs.any? { |attr| attr['onclick'] }
  394. code += "\n" + indent("onClick = { offset ->", depth + 1)
  395. code += "\n" + indent("annotatedText.getStringAnnotations(\"CLICKABLE\", offset, offset)", depth + 2)
  396. code += "\n" + indent(".firstOrNull()?.let { annotation ->", depth + 3)
  397. code += "\n" + indent("viewModel.handlePartialClick(annotation.item)", depth + 4)
  398. code += "\n" + indent("}", depth + 3)
  399. code += "\n" + indent("},", depth + 1)
  400. else
  401. code += "\n" + indent("onClick = { },", depth + 1)
  402. end
  403. # Add style (fontSize, color, etc. for the whole text)
  404. style_code = build_text_style(json_data, depth + 1, required_imports)
  405. if style_code
  406. code += style_code
  407. end
  408. # Build modifiers
  409. modifiers = []
  410. modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  411. modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  412. modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  413. modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  414. if modifiers.any?
  415. code += Helpers::ModifierBuilder.format(modifiers, depth)
  416. else
  417. code += "\n" + indent("modifier = Modifier", depth + 1)
  418. end
  419. code += "\n" + indent(")", depth)
  420. # Wrap with VisibilityWrapper if needed
  421. Helpers::VisibilityHelper.wrap_with_visibility(json_data, code, depth, required_imports)
  422. end
  423. 1 def self.build_text_style(json_data, depth, required_imports)
  424. 4 style_parts = []
  425. 4 if json_data['fontSize']
  426. 1 style_parts << "fontSize = #{json_data['fontSize']}.sp"
  427. end
  428. 4 if json_data['fontColor']
  429. 1 color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  430. 1 style_parts << "color = #{color_value}" if color_value
  431. end
  432. 4 if json_data['textAlign']
  433. 1 required_imports&.add(:text_align)
  434. 1 case json_data['textAlign'].downcase
  435. when 'center'
  436. 1 style_parts << "textAlign = TextAlign.Center"
  437. when 'right'
  438. style_parts << "textAlign = TextAlign.End"
  439. when 'left'
  440. style_parts << "textAlign = TextAlign.Start"
  441. end
  442. end
  443. 4 if style_parts.any?
  444. 3 required_imports&.add(:text_style)
  445. 3 return ",\n" + indent("style = TextStyle(#{style_parts.join(', ')})", depth)
  446. end
  447. nil
  448. end
  449. 1 def self.escape_string(text)
  450. 32 text.gsub('\\', '\\\\\\\\')
  451. .gsub('"', '\\"')
  452. .gsub("\n", '\\n')
  453. .gsub("\r", '\\r')
  454. .gsub("\t", '\\t')
  455. end
  456. 1 def self.quote(text)
  457. # Escape special characters properly
  458. 2 escaped = text.gsub('\\', '\\\\\\\\') # Escape backslashes first
  459. .gsub('"', '\\"') # Escape quotes
  460. .gsub("\n", '\\n') # Escape newlines
  461. .gsub("\r", '\\r') # Escape carriage returns
  462. .gsub("\t", '\\t') # Escape tabs
  463. 2 "\"#{escaped}\""
  464. end
  465. 1 def self.indent(text, level)
  466. 346 return text if level == 0
  467. 230 spaces = ' ' * level
  468. 230 text.split("\n").map { |line|
  469. 232 line.empty? ? line : spaces + line
  470. }.join("\n")
  471. end
  472. end
  473. end
  474. end
  475. end

lib/compose/components/textfield_component.rb

96.58% lines covered

146 relevant lines. 141 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class TextFieldComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # TextField uses 'text' for value and supports both 'hint' and 'placeholder'
  10. 37 value = Helpers::ResourceResolver.process_text(json_data['text'] || '', required_imports)
  11. 37 placeholder_text = json_data['hint'] || json_data['placeholder'] || ''
  12. 37 placeholder = placeholder_text.empty? ? '""' : Helpers::ResourceResolver.process_text(placeholder_text, required_imports)
  13. 37 is_secure = json_data['secure'] == true
  14. # Check if we need to wrap in Box for margins
  15. 37 has_margins = json_data['margins'] || json_data['topMargin'] || json_data['bottomMargin'] ||
  16. json_data['leftMargin'] || json_data['rightMargin']
  17. # Always use CustomTextField
  18. 37 required_imports&.add(:custom_textfield)
  19. 37 required_imports&.add(:visual_transformation) if is_secure
  20. 37 code = ""
  21. 37 if has_margins
  22. 2 required_imports&.add(:box)
  23. 2 code = indent("CustomTextFieldWithMargins(", depth)
  24. else
  25. 35 code = indent("CustomTextField(", depth)
  26. end
  27. 37 code += "\n" + indent("value = #{value},", depth + 1)
  28. # Handle onValueChange/onTextChange
  29. # Priority: onTextChange (explicit handler) > data binding > empty
  30. 37 if json_data['onTextChange']
  31. # Explicit event handler
  32. 1 code += "\n" + indent("onValueChange = { newValue -> viewModel.#{json_data['onTextChange']}(newValue) },", depth + 1)
  33. 36 elsif json_data['text'] && json_data['text'].match(/@\{([^}]+)\}/)
  34. # Data binding update
  35. 1 variable = extract_variable_name(json_data['text'])
  36. # Use a map update to notify the viewModel
  37. 1 code += "\n" + indent("onValueChange = { newValue -> viewModel.updateData(mapOf(\"#{variable}\" to newValue)) },", depth + 1)
  38. else
  39. 35 code += "\n" + indent("onValueChange = { },", depth + 1)
  40. end
  41. # For CustomTextFieldWithMargins, we need to specify modifiers differently
  42. 37 if has_margins
  43. # Box modifier with margins
  44. 2 box_modifiers = []
  45. 2 box_modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  46. 2 if box_modifiers.any?
  47. 2 code += "\n" + indent("boxModifier = Modifier", depth + 1)
  48. 2 box_modifiers.each do |mod|
  49. 2 code += "\n" + indent(" #{mod}", depth + 1)
  50. end
  51. 2 code += ","
  52. end
  53. # TextField modifier
  54. 2 textfield_modifiers = []
  55. 2 textfield_modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  56. 2 textfield_modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  57. 2 if textfield_modifiers.any?
  58. code += "\n" + indent("textFieldModifier = Modifier", depth + 1)
  59. textfield_modifiers.each do |mod|
  60. code += "\n" + indent(" #{mod}", depth + 1)
  61. end
  62. code += ","
  63. end
  64. else
  65. # Regular modifiers for CustomTextField
  66. 35 modifiers = []
  67. 35 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  68. 35 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  69. 35 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  70. 35 if modifiers.any?
  71. 1 code += "\n" + indent("modifier = Modifier", depth + 1)
  72. 1 modifiers.each do |mod|
  73. 2 code += "\n" + indent(" #{mod}", depth + 1)
  74. end
  75. 1 code += ","
  76. end
  77. end
  78. # Add placeholder/hint with styling
  79. 37 if placeholder && placeholder != '""'
  80. 3 if json_data['hintColor'] || json_data['hintFontSize'] || json_data['hintFont']
  81. # Complex placeholder with styling
  82. 1 placeholder_code = "placeholder = { Text("
  83. 1 placeholder_code += "\n" + indent("text = #{placeholder}", depth + 2)
  84. 1 if json_data['hintColor']
  85. 1 hint_color = Helpers::ResourceResolver.process_color(json_data['hintColor'], required_imports)
  86. 1 placeholder_code += ",\n" + indent("color = #{hint_color}", depth + 2)
  87. end
  88. 1 if json_data['hintFontSize']
  89. 1 placeholder_code += ",\n" + indent("fontSize = #{json_data['hintFontSize']}.sp", depth + 2)
  90. end
  91. 1 if json_data['hintFont'] == 'bold'
  92. 1 placeholder_code += ",\n" + indent("fontWeight = FontWeight.Bold", depth + 2)
  93. end
  94. 1 placeholder_code += "\n" + indent(") }", depth + 1)
  95. 1 code += "\n" + indent(placeholder_code, depth + 1) + ","
  96. else
  97. # Simple placeholder
  98. 2 code += "\n" + indent("placeholder = { Text(#{placeholder}) },", depth + 1)
  99. end
  100. end
  101. # Add visual transformation for secure fields
  102. 37 if is_secure
  103. 1 code += "\n" + indent("visualTransformation = PasswordVisualTransformation(),", depth + 1)
  104. end
  105. # Add custom TextField parameters
  106. # Shape with corner radius
  107. 37 if json_data['cornerRadius']
  108. 1 required_imports&.add(:shape)
  109. 1 code += "\n" + indent("shape = RoundedCornerShape(#{json_data['cornerRadius']}.dp),", depth + 1)
  110. end
  111. # Background colors
  112. 37 if json_data['background']
  113. 1 bg_color = Helpers::ResourceResolver.process_color(json_data['background'], required_imports)
  114. 1 code += "\n" + indent("backgroundColor = #{bg_color},", depth + 1)
  115. end
  116. 37 if json_data['highlightBackground']
  117. 1 highlight_bg_color = Helpers::ResourceResolver.process_color(json_data['highlightBackground'], required_imports)
  118. 1 code += "\n" + indent("highlightBackgroundColor = #{highlight_bg_color},", depth + 1)
  119. end
  120. # Border color for outlined text fields
  121. 37 if json_data['borderColor']
  122. 1 border_color = Helpers::ResourceResolver.process_color(json_data['borderColor'], required_imports)
  123. 1 code += "\n" + indent("borderColor = #{border_color},", depth + 1)
  124. end
  125. # Set isOutlined and isSecure flags
  126. # Automatically use outlined style if borderColor or borderWidth is specified
  127. 37 if json_data['outlined'] == true || json_data['borderColor'] || json_data['borderWidth']
  128. 2 code += "\n" + indent("isOutlined = true,", depth + 1)
  129. end
  130. 37 if is_secure
  131. 1 code += "\n" + indent("isSecure = true,", depth + 1)
  132. end
  133. # Text styling - always add this last before closing
  134. # Always include textStyle with at least a default color
  135. 37 required_imports&.add(:text_style)
  136. 37 style_parts = []
  137. 37 style_parts << "fontSize = #{json_data['fontSize']}.sp" if json_data['fontSize']
  138. # Use fontColor if specified, otherwise default to black
  139. 37 if json_data['fontColor']
  140. 1 color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  141. 1 style_parts << "color = #{color_value}" if color_value
  142. else
  143. # Default to black text
  144. 36 default_color = Helpers::ResourceResolver.process_color('#000000', required_imports)
  145. 36 style_parts << "color = #{default_color}"
  146. end
  147. 37 if json_data['textAlign']
  148. 3 required_imports&.add(:text_align)
  149. 3 case json_data['textAlign'].downcase
  150. when 'center'
  151. 1 style_parts << "textAlign = TextAlign.Center"
  152. when 'right'
  153. 1 style_parts << "textAlign = TextAlign.End"
  154. when 'left'
  155. 1 style_parts << "textAlign = TextAlign.Start"
  156. end
  157. end
  158. 37 if style_parts.any?
  159. # Remove trailing comma before adding textStyle
  160. 37 if code.end_with?(',')
  161. 37 code = code[0..-2]
  162. end
  163. 37 code += ",\n" + indent("textStyle = TextStyle(#{style_parts.join(', ')})", depth + 1)
  164. end
  165. # Add focus/blur event handlers
  166. 37 if json_data['onFocus']
  167. 1 code += ",\n" + indent("onFocus = { viewModel.#{json_data['onFocus']}() }", depth + 1)
  168. end
  169. 37 if json_data['onBlur']
  170. 1 code += ",\n" + indent("onBlur = { viewModel.#{json_data['onBlur']}() }", depth + 1)
  171. end
  172. 37 if json_data['onBeginEditing']
  173. 1 code += ",\n" + indent("onBeginEditing = { viewModel.#{json_data['onBeginEditing']}() }", depth + 1)
  174. end
  175. 37 if json_data['onEndEditing']
  176. 1 code += ",\n" + indent("onEndEditing = { viewModel.#{json_data['onEndEditing']}() }", depth + 1)
  177. end
  178. # Keyboard options (input and returnKeyType attributes)
  179. 37 keyboard_options = []
  180. 37 if json_data['input']
  181. 6 required_imports&.add(:keyboard_type)
  182. 6 keyboard_type = case json_data['input']
  183. when 'email'
  184. 1 'KeyboardType.Email'
  185. when 'password'
  186. 1 'KeyboardType.Password'
  187. when 'number'
  188. 1 'KeyboardType.Number'
  189. when 'decimal'
  190. 1 'KeyboardType.Decimal'
  191. when 'phone'
  192. 1 'KeyboardType.Phone'
  193. else
  194. 1 'KeyboardType.Text'
  195. end
  196. 6 keyboard_options << "keyboardType = #{keyboard_type}"
  197. end
  198. 37 if json_data['returnKeyType']
  199. 6 required_imports&.add(:ime_action)
  200. 6 ime_action = case json_data['returnKeyType']
  201. when 'Done'
  202. 1 'ImeAction.Done'
  203. when 'Next'
  204. 1 'ImeAction.Next'
  205. when 'Search'
  206. 1 'ImeAction.Search'
  207. when 'Send'
  208. 1 'ImeAction.Send'
  209. when 'Go'
  210. 1 'ImeAction.Go'
  211. else
  212. 1 'ImeAction.Default'
  213. end
  214. 6 keyboard_options << "imeAction = #{ime_action}"
  215. end
  216. 37 if keyboard_options.any?
  217. 12 code += ",\n" + indent("keyboardOptions = KeyboardOptions(#{keyboard_options.join(', ')})", depth + 1)
  218. end
  219. # Remove trailing comma and close
  220. 37 if code.end_with?(',')
  221. code = code[0..-2]
  222. end
  223. 37 code += "\n" + indent(")", depth)
  224. 37 code
  225. end
  226. 1 private
  227. 1 def self.extract_variable_name(text)
  228. 4 if text && text.match(/@\{([^}]+)\}/)
  229. 3 $1.split('.').last
  230. else
  231. 1 'value'
  232. end
  233. end
  234. 1 def self.indent(text, level)
  235. 226 return text if level == 0
  236. 151 spaces = ' ' * level
  237. 151 text.split("\n").map { |line|
  238. 156 line.empty? ? line : spaces + line
  239. }.join("\n")
  240. end
  241. end
  242. end
  243. end
  244. end

lib/compose/components/textview_component.rb

100.0% lines covered

134 relevant lines. 134 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class TextViewComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # TextView is multi-line text input (like TextArea)
  10. # Uses 'text' for value and 'placeholder' for hint
  11. 45 value = process_data_binding(json_data['text'] || '')
  12. 45 placeholder = json_data['placeholder'] || json_data['hint'] || ''
  13. # Check if we need to wrap in Box for margins
  14. 45 has_margins = json_data['margins'] || json_data['topMargin'] || json_data['bottomMargin'] ||
  15. json_data['leftMargin'] || json_data['rightMargin']
  16. # Always use CustomTextField
  17. 45 required_imports&.add(:custom_textfield)
  18. 45 code = ""
  19. 45 if has_margins
  20. 11 required_imports&.add(:box)
  21. 11 code = indent("CustomTextFieldWithMargins(", depth)
  22. else
  23. 34 code = indent("CustomTextField(", depth)
  24. end
  25. 45 code += "\n" + indent("value = #{value},", depth + 1)
  26. # onValueChange handler
  27. 45 if json_data['text'] && json_data['text'].match(/@\{([^}]+)\}/)
  28. 2 variable = extract_variable_name(json_data['text'])
  29. 2 code += "\n" + indent("onValueChange = { newValue -> viewModel.updateData(mapOf(\"#{variable}\" to newValue)) },", depth + 1)
  30. 43 elsif json_data['onTextChange']
  31. 1 code += "\n" + indent("onValueChange = { viewModel.#{json_data['onTextChange']}(it) },", depth + 1)
  32. else
  33. 42 code += "\n" + indent("onValueChange = { },", depth + 1)
  34. end
  35. # For CustomTextFieldWithMargins, we need to specify modifiers differently
  36. 45 if has_margins
  37. # Box modifier with margins
  38. 11 box_modifiers = []
  39. 11 box_modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  40. 11 if box_modifiers.any?
  41. 11 code += "\n" + indent("boxModifier = Modifier", depth + 1)
  42. 11 box_modifiers.each do |mod|
  43. 11 code += "\n" + indent(" #{mod}", depth + 1)
  44. end
  45. 11 code += ","
  46. end
  47. # TextField modifier
  48. 11 textfield_modifiers = []
  49. # Size - default to fillMaxWidth for text areas
  50. 11 if json_data['width'] == 'matchParent' || !json_data['width']
  51. 10 textfield_modifiers << ".fillMaxWidth()"
  52. else
  53. 1 textfield_modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  54. end
  55. # Height for multi-line
  56. 11 if json_data['height']
  57. 3 if json_data['height'] == 'matchParent'
  58. 1 textfield_modifiers << ".fillMaxHeight()"
  59. 2 elsif json_data['height'] == 'wrapContent'
  60. 1 textfield_modifiers << ".wrapContentHeight()"
  61. else
  62. 1 textfield_modifiers << ".height(#{json_data['height']}.dp)"
  63. end
  64. else
  65. # Default height for text area
  66. 8 textfield_modifiers << ".height(120.dp)"
  67. end
  68. 11 textfield_modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  69. 11 if textfield_modifiers.any?
  70. 11 code += "\n" + indent("textFieldModifier = Modifier", depth + 1)
  71. 11 textfield_modifiers.each do |mod|
  72. 22 code += "\n" + indent(" #{mod}", depth + 1)
  73. end
  74. 11 code += ","
  75. end
  76. else
  77. # Regular modifiers for CustomTextField
  78. 34 modifiers = []
  79. # Size - default to fillMaxWidth for text areas
  80. 34 if json_data['width'] == 'matchParent' || !json_data['width']
  81. 33 modifiers << ".fillMaxWidth()"
  82. else
  83. 1 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  84. end
  85. # Height for multi-line
  86. 34 if json_data['height']
  87. 3 if json_data['height'] == 'matchParent'
  88. 1 modifiers << ".fillMaxHeight()"
  89. 2 elsif json_data['height'] == 'wrapContent'
  90. 1 modifiers << ".wrapContentHeight()"
  91. else
  92. 1 modifiers << ".height(#{json_data['height']}.dp)"
  93. end
  94. else
  95. # Default height for text area
  96. 31 modifiers << ".height(120.dp)"
  97. end
  98. 34 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  99. 34 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  100. 34 if modifiers.any?
  101. 34 code += "\n" + indent("modifier = Modifier", depth + 1)
  102. 34 modifiers.each do |mod|
  103. 68 code += "\n" + indent(" #{mod}", depth + 1)
  104. end
  105. 34 code += ","
  106. end
  107. end
  108. # Placeholder
  109. 45 if placeholder && !placeholder.empty?
  110. 2 code += "\n" + indent("placeholder = { Text(#{quote(placeholder)}) },", depth + 1)
  111. end
  112. # Shape with corner radius
  113. 45 if json_data['cornerRadius']
  114. 1 required_imports&.add(:shape)
  115. 1 code += "\n" + indent("shape = RoundedCornerShape(#{json_data['cornerRadius']}.dp),", depth + 1)
  116. end
  117. # Background colors
  118. 45 if json_data['background']
  119. 1 bg_color = Helpers::ResourceResolver.process_color(json_data['background'], required_imports)
  120. 1 code += "\n" + indent("backgroundColor = #{bg_color},", depth + 1)
  121. end
  122. 45 if json_data['highlightBackground']
  123. 1 highlight_color = Helpers::ResourceResolver.process_color(json_data['highlightBackground'], required_imports)
  124. 1 code += "\n" + indent("highlightBackgroundColor = #{highlight_color},", depth + 1)
  125. end
  126. # Border color for outlined text fields
  127. 45 if json_data['borderColor']
  128. 1 border_color = Helpers::ResourceResolver.process_color(json_data['borderColor'], required_imports)
  129. 1 code += "\n" + indent("borderColor = #{border_color},", depth + 1)
  130. end
  131. # Set isOutlined flag (TextView usually wants outlined style)
  132. 45 code += "\n" + indent("isOutlined = true,", depth + 1)
  133. # Max lines for TextView
  134. 45 if json_data['maxLines']
  135. 1 code += "\n" + indent("maxLines = #{json_data['maxLines']},", depth + 1)
  136. else
  137. # Default to multiple lines
  138. 44 code += "\n" + indent("maxLines = Int.MAX_VALUE,", depth + 1)
  139. end
  140. # Single line false for multi-line
  141. 45 code += "\n" + indent("singleLine = false,", depth + 1)
  142. # Text styling
  143. 45 if json_data['fontSize'] || json_data['fontColor']
  144. 3 required_imports&.add(:text_style)
  145. 3 style_parts = []
  146. 3 style_parts << "fontSize = #{json_data['fontSize']}.sp" if json_data['fontSize']
  147. 3 if json_data['fontColor']
  148. 2 font_color = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
  149. 2 style_parts << "color = #{font_color}"
  150. end
  151. 3 if style_parts.any?
  152. 3 code += "\n" + indent("textStyle = TextStyle(#{style_parts.join(', ')})", depth + 1)
  153. end
  154. end
  155. # Keyboard options
  156. 45 if json_data['returnKeyType']
  157. 4 required_imports&.add(:ime_action)
  158. 4 ime_action = case json_data['returnKeyType']
  159. when 'Done'
  160. 1 'ImeAction.Done'
  161. when 'Next'
  162. 1 'ImeAction.Next'
  163. when 'Default'
  164. 1 'ImeAction.Default'
  165. else
  166. 1 'ImeAction.Default'
  167. end
  168. 4 code += ",\n" + indent("keyboardOptions = KeyboardOptions(imeAction = #{ime_action})", depth + 1)
  169. end
  170. # Enabled state
  171. 45 if json_data.key?('enabled')
  172. 3 if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
  173. 1 variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
  174. 1 code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
  175. else
  176. 2 code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
  177. end
  178. end
  179. # Remove trailing comma and close
  180. 45 if code.end_with?(',')
  181. 35 code = code[0..-2]
  182. end
  183. 45 code += "\n" + indent(")", depth)
  184. 45 code
  185. end
  186. 1 private
  187. 1 def self.process_data_binding(text)
  188. 49 return quote(text) unless text.is_a?(String)
  189. 49 if text.match(/@\{([^}]+)\}/)
  190. 4 variable = $1
  191. 4 if variable.include?(' ?? ')
  192. 2 parts = variable.split(' ?? ')
  193. 2 var_name = parts[0].strip
  194. 2 "data.#{var_name}"
  195. else
  196. 2 "data.#{variable}"
  197. end
  198. else
  199. 45 quote(text)
  200. end
  201. end
  202. 1 def self.extract_variable_name(text)
  203. 5 if text && text.match(/@\{([^}]+)\}/)
  204. 3 $1.split('.').last
  205. else
  206. 2 'value'
  207. end
  208. end
  209. 1 def self.quote(text)
  210. # Escape special characters properly
  211. 52 escaped = text.gsub('\\', '\\\\\\\\') # Escape backslashes first
  212. .gsub('"', '\\"') # Escape quotes
  213. .gsub("\n", '\\n') # Escape newlines
  214. .gsub("\r", '\\r') # Escape carriage returns
  215. .gsub("\t", '\\t') # Escape tabs
  216. 52 "\"#{escaped}\""
  217. end
  218. 1 def self.indent(text, level)
  219. 493 return text if level == 0
  220. 402 spaces = ' ' * level
  221. 402 text.split("\n").map { |line|
  222. 405 line.empty? ? line : spaces + line
  223. }.join("\n")
  224. end
  225. end
  226. end
  227. end
  228. end

lib/compose/components/toggle_component.rb

100.0% lines covered

49 relevant lines. 49 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class ToggleComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. # Toggle in iOS maps to Switch in Android
  10. 13 code = indent("Switch(", depth)
  11. # Checked state
  12. 13 checked_value = if json_data['data']
  13. 1 "@{#{json_data['data']}}"
  14. 12 elsif json_data['isOn']
  15. 1 json_data['isOn'].to_s
  16. else
  17. 11 'false'
  18. end
  19. # Process data binding
  20. 13 if checked_value.start_with?('@{')
  21. 1 variable = checked_value[2..-2]
  22. 1 code += "\n" + indent("checked = data.#{variable},", depth + 1)
  23. 1 code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{variable}\" to newValue)) },", depth + 1)
  24. else
  25. 12 code += "\n" + indent("checked = #{checked_value},", depth + 1)
  26. # Handle onclick
  27. 12 if json_data['onclick']
  28. 1 code += "\n" + indent("onCheckedChange = { ${data.toMap(viewModel)[\"#{json_data['onclick']}\"]} },", depth + 1)
  29. else
  30. 11 code += "\n" + indent("onCheckedChange = { },", depth + 1)
  31. end
  32. end
  33. # Build modifiers
  34. 13 modifiers = []
  35. 13 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  36. 13 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  37. 13 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  38. # Add weight modifier if in Row or Column
  39. 13 if parent_type == 'Row' || parent_type == 'Column'
  40. 2 modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
  41. end
  42. 13 code += Helpers::ModifierBuilder.format(modifiers, depth)
  43. # Colors if specified
  44. 13 if json_data['tintColor'] || json_data['backgroundColor']
  45. 3 required_imports&.add(:switch_colors)
  46. 3 code += ",\n" + indent("colors = SwitchDefaults.colors(", depth + 1)
  47. 3 if json_data['tintColor']
  48. 2 checkedthumbcolor_resolved = Helpers::ResourceResolver.process_color(json_data['tintColor'], required_imports)
  49. 2 code += "\n" + indent("checkedThumbColor = #{checkedthumbcolor_resolved},", depth + 2)
  50. 2 checkedtrackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['tintColor'], required_imports)
  51. 2 code += "\n" + indent("checkedTrackColor = #{checkedtrackcolor_resolved}.copy(alpha = 0.5f)", depth + 2)
  52. end
  53. 3 if json_data['backgroundColor']
  54. 2 code += ",\n" if json_data['tintColor']
  55. 2 uncheckedtrackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['backgroundColor'], required_imports)
  56. 2 code += "\n" + indent("uncheckedTrackColor = #{uncheckedtrackcolor_resolved}", depth + 2)
  57. end
  58. 3 code += "\n" + indent(")", depth + 1)
  59. end
  60. 13 code += "\n" + indent(")", depth)
  61. 13 code
  62. end
  63. 1 private
  64. 1 def self.indent(text, level)
  65. 67 return text if level == 0
  66. 40 spaces = ' ' * level
  67. 40 text.split("\n").map { |line|
  68. 42 line.empty? ? line : spaces + line
  69. }.join("\n")
  70. end
  71. end
  72. end
  73. end
  74. end

lib/compose/components/web_component.rb

100.0% lines covered

51 relevant lines. 51 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 require_relative '../helpers/resource_resolver'
  4. 1 module KjuiTools
  5. 1 module Compose
  6. 1 module Components
  7. 1 class WebComponent
  8. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  9. 19 required_imports&.add(:webview)
  10. # Web uses 'url' for the web page URL
  11. 19 url = if json_data['url'] && json_data['url'].match(/@\{([^}]+)\}/)
  12. 2 variable = $1
  13. 2 "data.#{variable}"
  14. 17 elsif json_data['url']
  15. 2 "\"#{json_data['url']}\""
  16. else
  17. 15 '""'
  18. end
  19. # Generate WebView using AndroidView
  20. 19 code = indent("AndroidView(", depth)
  21. 19 code += "\n" + indent("factory = { context ->", depth + 1)
  22. 19 code += "\n" + indent("WebView(context).apply {", depth + 2)
  23. # WebView settings
  24. 19 code += "\n" + indent("settings.javaScriptEnabled = #{json_data['javaScriptEnabled'] != false}", depth + 3)
  25. 19 if json_data['userAgent']
  26. 1 code += "\n" + indent("settings.userAgentString = \"#{json_data['userAgent']}\"", depth + 3)
  27. end
  28. 19 if json_data['allowZoom']
  29. 1 code += "\n" + indent("settings.builtInZoomControls = true", depth + 3)
  30. 1 code += "\n" + indent("settings.displayZoomControls = false", depth + 3)
  31. end
  32. # Load URL
  33. 19 code += "\n" + indent("loadUrl(#{url})", depth + 3)
  34. # WebViewClient for handling navigation
  35. 19 code += "\n" + indent("webViewClient = WebViewClient()", depth + 3)
  36. # WebChromeClient for JavaScript alerts
  37. 19 if json_data['javaScriptEnabled'] != false
  38. 17 code += "\n" + indent("webChromeClient = WebChromeClient()", depth + 3)
  39. end
  40. 19 code += "\n" + indent("}", depth + 2)
  41. 19 code += "\n" + indent("},", depth + 1)
  42. # Update callback to handle URL changes
  43. 19 code += "\n" + indent("update = { webView ->", depth + 1)
  44. 19 if json_data['url'] && json_data['url'].match(/@\{([^}]+)\}/)
  45. 2 code += "\n" + indent("webView.loadUrl(#{url})", depth + 2)
  46. end
  47. 19 code += "\n" + indent("},", depth + 1)
  48. # Build modifiers
  49. 19 modifiers = []
  50. # Default size for WebView
  51. 19 if !json_data['width'] && !json_data['height']
  52. 18 modifiers << ".fillMaxSize()"
  53. else
  54. 1 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  55. end
  56. 19 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  57. 19 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  58. # Border for WebView
  59. 19 if json_data['borderWidth'] && json_data['borderColor']
  60. 1 required_imports&.add(:border)
  61. 1 modifiers << ".border(#{json_data['borderWidth']}.dp, Helpers::ResourceResolver.process_color('#{json_data['borderColor']}', required_imports))"
  62. end
  63. 19 code += Helpers::ModifierBuilder.format(modifiers, depth)
  64. 19 code += "\n" + indent(")", depth)
  65. 19 code
  66. end
  67. 1 private
  68. 1 def self.indent(text, level)
  69. 236 return text if level == 0
  70. 197 spaces = ' ' * level
  71. 197 text.split("\n").map { |line|
  72. 200 line.empty? ? line : spaces + line
  73. }.join("\n")
  74. end
  75. end
  76. end
  77. end
  78. end

lib/compose/components/webview_component.rb

100.0% lines covered

41 relevant lines. 41 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative '../helpers/modifier_builder'
  3. 1 module KjuiTools
  4. 1 module Compose
  5. 1 module Components
  6. 1 class WebviewComponent
  7. 1 def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  8. 7 required_imports&.add(:webview)
  9. # WebView uses 'url' for the web page URL
  10. 7 url = if json_data['url'] && json_data['url'].match(/@\{([^}]+)\}/)
  11. 1 variable = $1
  12. 1 "data.#{variable}"
  13. 6 elsif json_data['url']
  14. 1 "\"#{json_data['url']}\""
  15. else
  16. 5 '""'
  17. end
  18. # Generate WebView using AndroidView
  19. 7 code = indent("AndroidView(", depth)
  20. 7 code += "\n" + indent("factory = { context ->", depth + 1)
  21. 7 code += "\n" + indent("WebView(context).apply {", depth + 2)
  22. # WebView settings
  23. 7 code += "\n" + indent("settings.javaScriptEnabled = #{json_data['javaScriptEnabled'] != false}", depth + 3)
  24. 7 if json_data['userAgent']
  25. 1 code += "\n" + indent("settings.userAgentString = \"#{json_data['userAgent']}\"", depth + 3)
  26. end
  27. 7 code += "\n" + indent("webViewClient = WebViewClient()", depth + 3)
  28. 7 code += "\n" + indent("webChromeClient = WebChromeClient()", depth + 3)
  29. # Load URL
  30. 7 code += "\n" + indent("loadUrl(#{url})", depth + 3)
  31. 7 code += "\n" + indent("}", depth + 2)
  32. 7 code += "\n" + indent("},", depth + 1)
  33. # Build modifiers
  34. 7 modifiers = []
  35. 7 modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  36. 7 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  37. 7 modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  38. 7 modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
  39. 7 if json_data['cornerRadius']
  40. 1 required_imports&.add(:shape)
  41. 1 modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
  42. end
  43. 7 code += Helpers::ModifierBuilder.format(modifiers, depth)
  44. 7 code += "\n" + indent(")", depth)
  45. 7 code
  46. end
  47. 1 private
  48. 1 def self.indent(text, level)
  49. 71 return text if level == 0
  50. 57 spaces = ' ' * level
  51. 57 text.split("\n").map { |line|
  52. 57 line.empty? ? line : spaces + line
  53. }.join("\n")
  54. end
  55. end
  56. end
  57. end
  58. end

lib/compose/compose_builder.rb

64.3% lines covered

395 relevant lines. 254 lines covered and 141 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require 'set'
  5. 1 require_relative '../core/config_manager'
  6. 1 require_relative '../core/project_finder'
  7. 1 require_relative '../core/logger'
  8. 1 require_relative 'style_loader'
  9. 1 require_relative 'data_model_updater'
  10. 1 require_relative 'helpers/import_manager'
  11. 1 require_relative 'helpers/modifier_builder'
  12. 1 require_relative 'components/text_component'
  13. 1 require_relative 'components/button_component'
  14. 1 require_relative 'components/textfield_component'
  15. 1 require_relative 'components/container_component'
  16. 1 require_relative 'components/image_component'
  17. 1 require_relative 'components/scrollview_component'
  18. 1 require_relative 'components/switch_component'
  19. 1 require_relative 'components/slider_component'
  20. 1 require_relative 'components/progress_component'
  21. 1 require_relative 'components/selectbox_component'
  22. 1 require_relative 'components/checkbox_component'
  23. 1 require_relative 'components/radio_component'
  24. 1 require_relative 'components/segment_component'
  25. 1 require_relative 'components/networkimage_component'
  26. 1 require_relative 'components/circleimage_component'
  27. 1 require_relative 'components/indicator_component'
  28. 1 require_relative 'components/textview_component'
  29. 1 require_relative 'components/collection_component'
  30. 1 require_relative 'components/table_component'
  31. 1 require_relative 'components/web_component'
  32. 1 require_relative 'components/gradientview_component'
  33. 1 require_relative 'components/blurview_component'
  34. 1 module KjuiTools
  35. 1 module Compose
  36. # Refactored ComposeBuilder - under 300 lines
  37. 1 class ComposeBuilder
  38. 1 def initialize
  39. 68 @config = Core::ConfigManager.load_config
  40. 68 @source_path = Core::ProjectFinder.get_full_source_path || Dir.pwd
  41. 68 source_directory = @config['source_directory'] || 'src/main'
  42. 68 @layouts_dir = File.join(@source_path, source_directory, @config['layouts_directory'] || 'assets/Layouts')
  43. 68 @view_dir = File.join(@source_path, source_directory, @config['view_directory'] || 'kotlin/views')
  44. 68 @package_name = @config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.app'
  45. 68 FileUtils.mkdir_p(@view_dir) unless File.exist?(@view_dir)
  46. end
  47. 1 def build(options = {})
  48. # Get all JSON files but exclude Resources folder
  49. 2 json_files = Dir.glob(File.join(@layouts_dir, '**/*.json')).reject do |file|
  50. 1 file.include?('/Resources/')
  51. end
  52. 2 if json_files.empty?
  53. 2 Core::Logger.warn "No JSON files found in #{@layouts_dir}"
  54. 2 return
  55. end
  56. # Update data models first
  57. data_updater = DataModelUpdater.new
  58. data_updater.update_data_models
  59. # Build each JSON file
  60. json_files.each { |file| build_file(file) }
  61. end
  62. 1 def build_file(json_file)
  63. 4 base_name = File.basename(json_file, '.json')
  64. 4 snake_case_name = to_snake_case(base_name)
  65. 4 pascal_case_name = to_pascal_case(base_name)
  66. begin
  67. 4 json_content = File.read(json_file)
  68. 4 json_data = JSON.parse(json_content)
  69. 3 json_data = StyleLoader.load_and_merge(json_data)
  70. 3 @required_imports = Set.new
  71. 3 @included_views = Set.new
  72. 3 @cell_views = Set.new
  73. 3 @custom_components = Set.new
  74. # Find the GeneratedView file
  75. 3 generated_view_file = File.join(@view_dir, snake_case_name, "#{pascal_case_name}GeneratedView.kt")
  76. 3 if File.exist?(generated_view_file)
  77. update_generated_file(generated_view_file, json_data)
  78. else
  79. 3 Core::Logger.warn "GeneratedView file not found: #{generated_view_file}"
  80. end
  81. 1 rescue JSON::ParserError => e
  82. 1 Core::Logger.error "Failed to parse #{json_file}: #{e.message}"
  83. rescue => e
  84. Core::Logger.error "Failed to process #{json_file}: #{e.message}"
  85. end
  86. end
  87. 1 private
  88. 1 def generate_component(json_data, depth = 0, parent_type = nil)
  89. 34 return "" unless json_data.is_a?(Hash)
  90. 32 component_type = json_data['type'] || 'View'
  91. # Handle includes
  92. 32 return generate_include(json_data, depth) if json_data['include']
  93. # Generate component based on type
  94. 32 case component_type
  95. when 'ScrollView', 'Scroll'
  96. 2 result = Components::ScrollViewComponent.generate(json_data, depth, @required_imports, parent_type)
  97. 2 handle_container_result(result, depth, parent_type)
  98. when 'SafeAreaView'
  99. generate_safe_area_view(json_data, depth)
  100. when 'View'
  101. 1 result = Components::ContainerComponent.generate(json_data, depth, @required_imports, parent_type)
  102. 1 handle_container_result(result, depth, parent_type)
  103. when 'Text', 'Label'
  104. 5 Components::TextComponent.generate(json_data, depth, @required_imports, parent_type)
  105. when 'Button'
  106. 1 Components::ButtonComponent.generate(json_data, depth, @required_imports, parent_type)
  107. when 'Image'
  108. 1 Components::ImageComponent.generate(json_data, depth, @required_imports, parent_type)
  109. when 'TextField'
  110. 1 Components::TextFieldComponent.generate(json_data, depth, @required_imports, parent_type)
  111. when 'Switch', 'Toggle'
  112. 2 Components::SwitchComponent.generate(json_data, depth, @required_imports, parent_type)
  113. when 'Slider'
  114. 1 Components::SliderComponent.generate(json_data, depth, @required_imports, parent_type)
  115. when 'Progress'
  116. 1 Components::ProgressComponent.generate(json_data, depth, @required_imports, parent_type)
  117. when 'SelectBox'
  118. 1 Components::SelectBoxComponent.generate(json_data, depth, @required_imports, parent_type)
  119. when 'Check', 'Checkbox'
  120. 2 Components::CheckboxComponent.generate(json_data, depth, @required_imports, parent_type)
  121. when 'Radio'
  122. 1 Components::RadioComponent.generate(json_data, depth, @required_imports, parent_type)
  123. when 'Segment'
  124. 1 Components::SegmentComponent.generate(json_data, depth, @required_imports, parent_type)
  125. when 'NetworkImage'
  126. 1 Components::NetworkImageComponent.generate(json_data, depth, @required_imports, parent_type)
  127. when 'CircleImage'
  128. 1 Components::CircleImageComponent.generate(json_data, depth, @required_imports, parent_type)
  129. when 'Indicator'
  130. 1 Components::IndicatorComponent.generate(json_data, depth, @required_imports, parent_type)
  131. when 'TextView'
  132. 1 Components::TextViewComponent.generate(json_data, depth, @required_imports, parent_type)
  133. when 'Collection'
  134. # Extract cell classes for imports
  135. 1 cell_classes = json_data['cellClasses'] || []
  136. 1 cell_classes.each do |cell_class|
  137. 1 @cell_views&.add(cell_class)
  138. end
  139. 1 Components::CollectionComponent.generate(json_data, depth, @required_imports, parent_type)
  140. when 'Table'
  141. 1 Components::TableComponent.generate(json_data, depth, @required_imports, parent_type)
  142. when 'Web'
  143. 1 Components::WebComponent.generate(json_data, depth, @required_imports, parent_type)
  144. when 'GradientView'
  145. 1 result = Components::GradientviewComponent.generate(json_data, depth, @required_imports, parent_type)
  146. 1 handle_container_result(result, depth, parent_type)
  147. when 'BlurView'
  148. 1 result = Components::BlurviewComponent.generate(json_data, depth, @required_imports, parent_type)
  149. 1 handle_container_result(result, depth, parent_type)
  150. when 'Spacer'
  151. 2 "Spacer(modifier = Modifier.height(#{json_data['height'] || 8}.dp))"
  152. else
  153. # Check for custom components
  154. 1 check_custom_component(component_type, json_data, depth, parent_type)
  155. end
  156. end
  157. 1 def check_custom_component(component_type, json_data, depth, parent_type)
  158. # Try to load custom component mappings if they exist
  159. 1 mappings_file = File.join(File.dirname(__FILE__), 'components', 'extensions', 'component_mappings.rb')
  160. 1 if File.exist?(mappings_file)
  161. require_relative 'components/extensions/component_mappings'
  162. if defined?(Components::Extensions::COMPONENT_MAPPINGS)
  163. component_class = Components::Extensions::COMPONENT_MAPPINGS[component_type]
  164. if component_class
  165. # Load the custom component file
  166. snake_case_name = component_type.gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
  167. .gsub(/([a-z\d])([A-Z])/,'\1_\2')
  168. .downcase
  169. component_file = File.join(File.dirname(__FILE__), 'components', 'extensions', "#{snake_case_name}_component.rb")
  170. if File.exist?(component_file)
  171. require_relative "components/extensions/#{snake_case_name}_component"
  172. # Add import for the custom component
  173. @custom_components&.add(component_type)
  174. result = component_class.generate(json_data, depth, @required_imports, parent_type)
  175. # Handle container components that return metadata
  176. if result.is_a?(Hash) && result[:children]
  177. return handle_container_result(result, depth, parent_type)
  178. else
  179. return result
  180. end
  181. end
  182. end
  183. end
  184. end
  185. 1 "// TODO: Implement component type: #{component_type}"
  186. end
  187. 1 def handle_container_result(result, depth, parent_type = nil)
  188. 7 if result.is_a?(Hash)
  189. 6 code = result[:code]
  190. 6 children = result[:children] || []
  191. 6 layout_type = result[:layout_type] || parent_type
  192. 6 children.each do |child|
  193. 1 child_code = generate_component(child, depth + 1, layout_type)
  194. 1 code += "\n" + child_code unless child_code.empty?
  195. end
  196. 6 code += result[:closing] if result[:closing]
  197. 6 code
  198. else
  199. 1 result
  200. end
  201. end
  202. 1 def generate_safe_area_view(json_data, depth)
  203. 3 code = indent("Box(", depth)
  204. 3 modifiers = ["Modifier", ".fillMaxSize()", ".systemBarsPadding()"]
  205. 3 modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  206. 3 modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, @required_imports))
  207. 3 code += Helpers::ModifierBuilder.format(modifiers, depth)
  208. 3 code += "\n" + indent(") {", depth)
  209. 3 children = json_data['child'] || []
  210. 3 children = [children] unless children.is_a?(Array)
  211. 3 children.each do |child|
  212. 2 child_code = generate_component(child, depth + 1)
  213. 2 code += "\n" + child_code unless child_code.empty?
  214. end
  215. 3 code += "\n" + indent("}", depth)
  216. 3 code
  217. end
  218. 1 def generate_include(json_data, depth)
  219. 5 include_name = json_data['include']
  220. 5 pascal_name = to_pascal_case(include_name)
  221. 5 snake_name = to_snake_case(include_name)
  222. # Check if we should use DynamicView
  223. 5 use_dynamic = json_data['dynamic'] == true
  224. # Track this included view for imports
  225. 5 @included_views&.add(snake_name) unless use_dynamic
  226. # Track required imports for LaunchedEffect if we have data bindings
  227. 5 has_data_bindings = false
  228. # Check if there's data or shared_data to pass
  229. 5 include_data = json_data['data'] || {}
  230. 5 shared_data = json_data['shared_data'] || {}
  231. # Check for @{} bindings in data
  232. 5 include_data.each do |key, value|
  233. 1 if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  234. 1 has_data_bindings = true
  235. 1 unless use_dynamic
  236. 1 @required_imports.add(:LaunchedEffect)
  237. 1 @required_imports.add(:remember)
  238. end
  239. 1 break
  240. end
  241. end
  242. # If using dynamic view, generate DynamicView call
  243. 5 if use_dynamic
  244. 1 return generate_dynamic_include(json_data, depth, include_data, shared_data, has_data_bindings)
  245. end
  246. # Generate unique instance ID for this include
  247. 4 instance_id = "#{to_camel_case(include_name)}Instance#{depth}"
  248. 4 code = ""
  249. # Create a remember block for the ViewModel instance
  250. 4 code += indent("val context = LocalContext.current", depth)
  251. 4 code += "\n"
  252. 4 code += indent("val #{instance_id} = remember { #{pascal_name}ViewModel(context.applicationContext as Application) }", depth)
  253. 4 code += "\n"
  254. # If we have data bindings, add LaunchedEffect to update on parent data changes
  255. 4 if has_data_bindings || shared_data.any?
  256. 2 code += "\n" + indent("// Update included view when parent data changes", depth)
  257. 2 code += "\n" + indent("LaunchedEffect(", depth)
  258. # Add keys for all bound variables
  259. 2 keys = []
  260. 2 include_data.each do |key, value|
  261. 1 if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  262. 1 variable = $1
  263. 1 keys << "data.#{variable}"
  264. end
  265. end
  266. 2 shared_data.each do |key, value|
  267. 1 if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  268. 1 variable = $1
  269. 1 keys << "data.#{variable}"
  270. end
  271. end
  272. 2 if keys.any?
  273. 2 code += keys.join(", ")
  274. else
  275. code += "Unit"
  276. end
  277. 2 code += ") {"
  278. 2 code += "\n" + indent("val updates = mutableMapOf<String, Any>()", depth + 1)
  279. # Process data (one-way binding from parent to child)
  280. 2 include_data.each do |key, value|
  281. 1 if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  282. # This is a data binding reference to parent data
  283. 1 variable = $1
  284. 1 code += "\n" + indent("updates[\"#{key}\"] = data.#{variable}", depth + 1)
  285. else
  286. # This is a static value
  287. formatted_value = format_value_for_kotlin(value)
  288. code += "\n" + indent("updates[\"#{key}\"] = #{formatted_value}", depth + 1)
  289. end
  290. end
  291. # Process shared_data (two-way binding)
  292. 2 if shared_data.any?
  293. 1 code += "\n" + indent("// Shared data for two-way binding", depth + 1)
  294. 1 shared_data.each do |key, value|
  295. 1 if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  296. # This creates a two-way binding
  297. 1 variable = $1
  298. 1 code += "\n" + indent("updates[\"#{key}\"] = data.#{variable}", depth + 1)
  299. # TODO: Also need to update parent when child changes
  300. else
  301. # Static value for shared_data
  302. formatted_value = format_value_for_kotlin(value)
  303. code += "\n" + indent("updates[\"#{key}\"] = #{formatted_value}", depth + 1)
  304. end
  305. end
  306. end
  307. 2 code += "\n" + indent("#{instance_id}.updateData(updates)", depth + 1)
  308. 2 code += "\n" + indent("}", depth)
  309. end
  310. # Generate the included view call
  311. 4 code += "\n" + indent("#{pascal_name}View(", depth)
  312. 4 code += "\n" + indent("viewModel = #{instance_id}", depth + 1)
  313. 4 code += "\n" + indent(")", depth)
  314. 4 code
  315. end
  316. 1 def generate_dynamic_include(json_data, depth, include_data, shared_data, has_data_bindings)
  317. 1 include_name = json_data['include']
  318. # Add required imports for SafeDynamicView
  319. 1 @required_imports.add(:safe_dynamic_view)
  320. 1 code = ""
  321. # Build data map from bindings and current data
  322. 1 code += indent("// Build data map with bindings", depth)
  323. 1 code += "\n" + indent("val dynamicData = mutableMapOf<String, Any>()", depth)
  324. # Add all current data values
  325. 1 code += "\n" + indent("// Add current data values", depth)
  326. 1 code += "\n" + indent("data.forEach { (key, value) ->", depth)
  327. 1 code += "\n" + indent("dynamicData[key] = value", depth + 1)
  328. 1 code += "\n" + indent("}", depth)
  329. # Process include_data bindings
  330. 1 if include_data.any?
  331. code += "\n" + indent("// Process include data bindings", depth)
  332. include_data.each do |key, value|
  333. if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  334. # This is a data binding reference to parent data
  335. variable = $1
  336. code += "\n" + indent("data[\"#{variable}\"]?.let { dynamicData[\"#{key}\"] = it }", depth)
  337. else
  338. # This is a static value
  339. formatted_value = format_value_for_kotlin(value)
  340. code += "\n" + indent("dynamicData[\"#{key}\"] = #{formatted_value}", depth)
  341. end
  342. end
  343. end
  344. # Process shared_data bindings
  345. 1 if shared_data.any?
  346. code += "\n" + indent("// Process shared data bindings", depth)
  347. shared_data.each do |key, value|
  348. if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
  349. # This creates a two-way binding
  350. variable = $1
  351. code += "\n" + indent("data[\"#{variable}\"]?.let { dynamicData[\"#{key}\"] = it }", depth)
  352. else
  353. # Static value for shared_data
  354. formatted_value = format_value_for_kotlin(value)
  355. code += "\n" + indent("dynamicData[\"#{key}\"] = #{formatted_value}", depth)
  356. end
  357. end
  358. end
  359. # Add all viewModel methods as functions to the data map
  360. 1 code += "\n" + indent("// Add viewModel methods as event handlers", depth)
  361. 1 code += "\n" + indent("// Note: Add specific method references as needed", depth)
  362. 1 code += "\n" + indent("// Example: dynamicData[\"onButtonClick\"] = { viewModel.onButtonClick() }", depth)
  363. # Call SafeDynamicView
  364. 1 code += "\n" + indent("// Render dynamic view", depth)
  365. 1 code += "\n" + indent("SafeDynamicView(", depth)
  366. 1 code += "\n" + indent("layoutName = \"#{include_name}\",", depth + 1)
  367. 1 code += "\n" + indent("data = dynamicData", depth + 1)
  368. 1 code += "\n" + indent(")", depth)
  369. 1 code
  370. end
  371. 1 def format_value_for_kotlin(value)
  372. 7 case value
  373. when String
  374. 1 "\"#{value.gsub('"', '\\"')}\""
  375. when Integer
  376. 1 value.to_s
  377. when Float
  378. 1 "#{value}f"
  379. when TrueClass, FalseClass
  380. 2 value.to_s
  381. when nil
  382. 1 "null"
  383. else
  384. 1 "\"#{value}\""
  385. end
  386. end
  387. 1 def update_generated_file(file_path, json_data)
  388. existing_content = File.read(file_path)
  389. if existing_content.include?('// >>> GENERATED_CODE_START') &&
  390. existing_content.include?('// >>> GENERATED_CODE_END')
  391. # Extract the layout name from file path
  392. layout_name = File.basename(File.dirname(file_path))
  393. # Generate both static and dynamic versions
  394. static_content = generate_component(json_data, 1)
  395. dynamic_content = generate_dynamic_view_content(layout_name, json_data, 1)
  396. # Create content that switches based on DynamicModeManager
  397. composable_content = generate_mode_aware_content(layout_name, static_content, dynamic_content, 1)
  398. updated_content = existing_content.gsub(
  399. /\/\/ >>> GENERATED_CODE_START.*?\/\/ >>> GENERATED_CODE_END/m,
  400. "// >>> GENERATED_CODE_START\n#{composable_content} // >>> GENERATED_CODE_END"
  401. )
  402. updated_content = update_imports(updated_content)
  403. File.write(file_path, updated_content)
  404. Core::Logger.success "Updated: #{file_path}"
  405. else
  406. Core::Logger.warn "Generated code markers not found in #{file_path}"
  407. end
  408. end
  409. 1 def generate_mode_aware_content(layout_name, static_content, dynamic_content, depth)
  410. indent_str = " " * depth
  411. code = ""
  412. code += "#{indent_str}// Check if Dynamic Mode is active\n"
  413. code += "#{indent_str}if (DynamicModeManager.isActive()) {\n"
  414. code += "#{indent_str} // Dynamic Mode - use SafeDynamicView for real-time updates\n"
  415. code += dynamic_content
  416. code += "#{indent_str}} else {\n"
  417. code += "#{indent_str} // Static Mode - use generated code\n"
  418. code += " #{static_content}"
  419. code += "#{indent_str}}\n"
  420. # Add required imports for DynamicModeManager
  421. @required_imports.add(:dynamic_mode_manager)
  422. # SafeDynamicView import is already added in generate_dynamic_view
  423. code
  424. end
  425. 1 def generate_dynamic_view_content(layout_name, json_data, depth)
  426. indent_str = " " * depth
  427. code = ""
  428. code += "#{indent_str} SafeDynamicView(\n"
  429. code += "#{indent_str} layoutName = \"#{layout_name}\",\n"
  430. code += "#{indent_str} data = data.toMap(viewModel),\n"
  431. code += "#{indent_str} fallback = {\n"
  432. code += "#{indent_str} // Show error or loading state when dynamic view is not available\n"
  433. code += "#{indent_str} Box(\n"
  434. code += "#{indent_str} modifier = Modifier.fillMaxSize(),\n"
  435. code += "#{indent_str} contentAlignment = Alignment.Center\n"
  436. code += "#{indent_str} ) {\n"
  437. code += "#{indent_str} Text(\n"
  438. code += "#{indent_str} text = \"Dynamic view not available\",\n"
  439. code += "#{indent_str} color = Color.Gray\n"
  440. code += "#{indent_str} )\n"
  441. code += "#{indent_str} }\n"
  442. code += "#{indent_str} },\n"
  443. code += "#{indent_str} onError = { error ->\n"
  444. code += "#{indent_str} // Log error or show error UI\n"
  445. code += "#{indent_str} android.util.Log.e(\"DynamicView\", \"Error loading #{layout_name}: \\$error\")\n"
  446. code += "#{indent_str} },\n"
  447. code += "#{indent_str} onLoading = {\n"
  448. code += "#{indent_str} // Show loading indicator\n"
  449. code += "#{indent_str} Box(\n"
  450. code += "#{indent_str} modifier = Modifier.fillMaxSize(),\n"
  451. code += "#{indent_str} contentAlignment = Alignment.Center\n"
  452. code += "#{indent_str} ) {\n"
  453. code += "#{indent_str} CircularProgressIndicator()\n"
  454. code += "#{indent_str} }\n"
  455. code += "#{indent_str} }\n"
  456. code += "#{indent_str} ) { jsonContent ->\n"
  457. code += "#{indent_str} // Parse and render the dynamic JSON content\n"
  458. code += "#{indent_str} // This will be handled by the DynamicView implementation\n"
  459. code += "#{indent_str} }\n"
  460. # Add required imports
  461. @required_imports.add(:safe_dynamic_view)
  462. @required_imports.add(:circular_progress_indicator)
  463. @required_imports.add(:box)
  464. code
  465. end
  466. 1 def update_imports(content)
  467. 1 imports_map = Helpers::ImportManager.get_imports_map(@package_name)
  468. 1 imports_to_add = []
  469. 1 @required_imports.each do |import_type|
  470. 1 import_lines = imports_map[import_type]
  471. 1 if import_lines
  472. if import_lines.is_a?(Array)
  473. imports_to_add.concat(import_lines)
  474. else
  475. imports_to_add << import_lines
  476. end
  477. end
  478. end
  479. # Add imports for included views
  480. 1 if @included_views && @included_views.any?
  481. # Add necessary imports for creating ViewModels
  482. imports_to_add << "import android.app.Application" unless imports_to_add.include?("import android.app.Application")
  483. imports_to_add << "import androidx.compose.ui.platform.LocalContext" unless imports_to_add.include?("import androidx.compose.ui.platform.LocalContext")
  484. @included_views.each do |view_name|
  485. pascal_name = to_pascal_case(view_name)
  486. view_import = "import #{@package_name}.views.#{view_name}.#{pascal_name}View"
  487. data_import = "import #{@package_name}.data.#{pascal_name}Data"
  488. viewmodel_import = "import #{@package_name}.viewmodels.#{pascal_name}ViewModel"
  489. imports_to_add << view_import unless imports_to_add.include?(view_import)
  490. imports_to_add << data_import unless imports_to_add.include?(data_import)
  491. imports_to_add << viewmodel_import unless imports_to_add.include?(viewmodel_import)
  492. end
  493. end
  494. # Add imports for custom components
  495. 1 if @custom_components && @custom_components.any?
  496. @custom_components.each do |component_name|
  497. component_import = "import #{@package_name}.extensions.#{component_name}"
  498. imports_to_add << component_import unless imports_to_add.include?(component_import)
  499. end
  500. end
  501. # Add imports for cell views (used in Collection components)
  502. 1 if @cell_views && @cell_views.any?
  503. # Add necessary imports for creating ViewModels in collections
  504. imports_to_add << "import androidx.lifecycle.viewmodel.compose.viewModel" unless imports_to_add.include?("import androidx.lifecycle.viewmodel.compose.viewModel")
  505. # First, remove any old/incorrect cell view imports
  506. lines = content.split("\n")
  507. @cell_views.each do |cell_class|
  508. snake_name = to_snake_case(cell_class)
  509. # Remove any existing imports with incorrect capitalization
  510. lines.reject! { |line| line.match(/^import #{Regexp.escape(@package_name)}\.views\.#{Regexp.escape(snake_name)}\.\w+View$/) }
  511. lines.reject! { |line| line.match(/^import #{Regexp.escape(@package_name)}\.data\.\w+Data$/) && line.downcase.include?(cell_class.downcase) }
  512. lines.reject! { |line| line.match(/^import #{Regexp.escape(@package_name)}\.viewmodels\.\w+ViewModel$/) && line.downcase.include?(cell_class.downcase) }
  513. end
  514. content = lines.join("\n")
  515. @cell_views.each do |cell_class|
  516. # Cell class names are already in PascalCase (e.g., "ProductCell")
  517. # Convert to snake_case for folder path
  518. snake_name = to_snake_case(cell_class)
  519. # Keep the original cell class name for the class itself
  520. # Add imports for the cell view and data
  521. view_import = "import #{@package_name}.views.#{snake_name}.#{cell_class}View"
  522. data_import = "import #{@package_name}.data.#{cell_class}Data"
  523. viewmodel_import = "import #{@package_name}.viewmodels.#{cell_class}ViewModel"
  524. imports_to_add << view_import unless imports_to_add.include?(view_import)
  525. imports_to_add << data_import unless imports_to_add.include?(data_import)
  526. imports_to_add << viewmodel_import unless imports_to_add.include?(viewmodel_import)
  527. end
  528. end
  529. 1 if imports_to_add.any?
  530. lines = content.split("\n")
  531. package_index = lines.find_index { |line| line.start_with?("package ") }
  532. if package_index
  533. last_import_index = lines.each_with_index.select { |line, i|
  534. i > package_index && line.start_with?("import ")
  535. }.map(&:last).max || package_index
  536. imports_to_add.each do |import|
  537. unless lines.any? { |line| line == import }
  538. lines.insert(last_import_index + 1, import)
  539. last_import_index += 1
  540. end
  541. end
  542. content = lines.join("\n")
  543. end
  544. end
  545. 1 content
  546. end
  547. 1 def process_data_binding(text)
  548. 3 return quote(text) unless text.is_a?(String)
  549. 3 if text.match(/@\{([^}]+)\}/)
  550. 2 variable = $1
  551. 2 if variable.include?(' ?? ')
  552. 1 var_name = variable.split(' ?? ')[0].strip
  553. 1 "\"\${data.#{var_name}}\""
  554. else
  555. 1 "\"\${data.#{variable}}\""
  556. end
  557. else
  558. 1 quote(text)
  559. end
  560. end
  561. 1 def quote(text)
  562. # Escape special characters properly
  563. 4 escaped = text.gsub('\\', '\\\\\\\\') # Escape backslashes first
  564. .gsub('"', '\\"') # Escape quotes
  565. .gsub("\n", '\\n') # Escape newlines
  566. .gsub("\r", '\\r') # Escape carriage returns
  567. .gsub("\t", '\\t') # Escape tabs
  568. 4 "\"#{escaped}\""
  569. end
  570. 1 def indent(text, level)
  571. 58 return text if level == 0
  572. 15 spaces = ' ' * level
  573. 15 text.split("\n").map { |line|
  574. 15 line.empty? ? line : spaces + line
  575. }.join("\n")
  576. end
  577. 1 def to_pascal_case(str)
  578. 16 str.split(/[_\-]/).map(&:capitalize).join
  579. end
  580. 1 def to_camel_case(str)
  581. 5 pascal = to_pascal_case(str)
  582. 5 pascal[0].downcase + pascal[1..-1]
  583. end
  584. 1 def to_snake_case(str)
  585. 10 str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
  586. .gsub(/([a-z\d])([A-Z])/, '\1_\2')
  587. .downcase
  588. end
  589. end
  590. end
  591. end

lib/compose/data_model_updater.rb

86.78% lines covered

227 relevant lines. 197 lines covered and 30 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require 'set'
  5. 1 require_relative '../core/config_manager'
  6. 1 require_relative '../core/project_finder'
  7. 1 require_relative 'style_loader'
  8. 1 module KjuiTools
  9. 1 module Compose
  10. 1 class DataModelUpdater
  11. 1 def initialize
  12. 48 @config = Core::ConfigManager.load_config
  13. 48 @source_path = Core::ProjectFinder.get_full_source_path || Dir.pwd
  14. 48 source_directory = @config['source_directory'] || 'src/main'
  15. 48 @layouts_dir = File.join(@source_path, source_directory, @config['layouts_directory'] || 'assets/Layouts')
  16. 48 @data_dir = File.join(@source_path, source_directory, @config['data_directory'] || 'kotlin/com/example/kotlinjsonui/sample/data')
  17. 48 @package_name = @config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.app'
  18. end
  19. 1 def update_data_models
  20. # Process all JSON files in Layouts directory but exclude Resources folder
  21. 10 json_files = Dir.glob(File.join(@layouts_dir, '**/*.json')).reject do |file|
  22. 13 file.include?('/Resources/')
  23. end
  24. 10 puts " Updating data models for #{json_files.length} files..."
  25. 10 json_files.each do |json_file|
  26. 10 process_json_file(json_file)
  27. end
  28. end
  29. 1 private
  30. 1 def process_json_file(json_file)
  31. 10 json_content = File.read(json_file)
  32. 10 json_data = JSON.parse(json_content)
  33. # Load and merge styles into the JSON data
  34. 10 json_data = StyleLoader.load_and_merge(json_data)
  35. # Extract data properties from JSON
  36. 10 data_properties = extract_data_properties(json_data)
  37. # Extract onclick actions from JSON (now includes actions from styles)
  38. 10 onclick_actions = extract_onclick_actions(json_data)
  39. # Always create/update data file, even if no properties
  40. # Get the view name from file path
  41. 10 base_name = File.basename(json_file, '.json')
  42. # Update the Data model file
  43. 10 update_data_file(base_name, data_properties, onclick_actions)
  44. end
  45. 1 def extract_onclick_actions(json_data, actions = Set.new)
  46. 27 if json_data.is_a?(Hash)
  47. # Check for onclick attribute
  48. 26 if json_data['onclick'] && json_data['onclick'].is_a?(String)
  49. 8 actions.add(json_data['onclick'])
  50. end
  51. # Process children
  52. 26 if json_data['child']
  53. 9 if json_data['child'].is_a?(Array)
  54. 7 json_data['child'].each do |child|
  55. 9 extract_onclick_actions(child, actions)
  56. end
  57. else
  58. 2 extract_onclick_actions(json_data['child'], actions)
  59. end
  60. end
  61. 1 elsif json_data.is_a?(Array)
  62. 1 json_data.each do |item|
  63. 2 extract_onclick_actions(item, actions)
  64. end
  65. end
  66. 27 actions.to_a
  67. end
  68. 1 def extract_data_properties(json_data, properties = [], depth = 0)
  69. 17 if json_data.is_a?(Hash)
  70. # Stop if this is an include - includes have their own data models
  71. 17 return properties if json_data['include']
  72. # Check for data section at any level, but only process the first one found
  73. 16 if json_data['data'] && properties.empty?
  74. 5 if json_data['data'].is_a?(Array)
  75. 4 json_data['data'].each do |data_item|
  76. 5 if data_item.is_a?(Hash) && data_item['name']
  77. # Check if property already exists (by name) to avoid duplicates
  78. 6 unless properties.any? { |p| p['name'] == data_item['name'] }
  79. 5 properties << data_item
  80. end
  81. end
  82. end
  83. 1 elsif json_data['data'].is_a?(Hash)
  84. # Handle simple data object format from styles
  85. 1 json_data['data'].each do |name, value|
  86. 10 unless properties.any? { |p| p['name'] == name }
  87. # Infer type from value
  88. 4 class_type = if value.is_a?(Integer)
  89. 1 'Int'
  90. 3 elsif value.is_a?(Float)
  91. 1 'Float'
  92. 2 elsif value.is_a?(TrueClass) || value.is_a?(FalseClass)
  93. 1 'Boolean'
  94. else
  95. 1 'String'
  96. end
  97. 4 properties << {
  98. 'name' => name,
  99. 'class' => class_type,
  100. 'defaultValue' => value
  101. }
  102. end
  103. end
  104. end
  105. end
  106. # If we haven't found data yet, continue searching in children
  107. 16 if properties.empty? && json_data['child']
  108. 6 if json_data['child'].is_a?(Array)
  109. 5 json_data['child'].each do |child|
  110. 6 extract_data_properties(child, properties, depth + 1)
  111. # Stop after finding the first data section
  112. 6 break unless properties.empty?
  113. end
  114. else
  115. 1 extract_data_properties(json_data['child'], properties, depth + 1)
  116. end
  117. end
  118. elsif json_data.is_a?(Array)
  119. json_data.each do |item|
  120. extract_data_properties(item, properties, depth)
  121. # Stop after finding the first data section
  122. break unless properties.empty?
  123. end
  124. end
  125. 16 properties
  126. end
  127. 1 def update_data_file(base_name, data_properties, onclick_actions = [])
  128. # Convert base_name to PascalCase for searching
  129. 10 pascal_view_name = to_pascal_case(base_name)
  130. # Check for existing file with different casing
  131. 10 existing_file = find_existing_data_file(pascal_view_name)
  132. 10 if existing_file
  133. # Extract the actual data class name from the existing file
  134. existing_class_name = extract_class_name(existing_file)
  135. if existing_class_name
  136. # Use the exact class name from the existing file
  137. view_name = existing_class_name.sub(/Data$/, '')
  138. else
  139. # Fallback to pascal case if we can't extract the name
  140. view_name = pascal_view_name
  141. end
  142. data_file_path = existing_file
  143. else
  144. # For new files, use pascal case
  145. 10 view_name = pascal_view_name
  146. 10 data_file_path = File.join(@data_dir, "#{view_name}Data.kt")
  147. # If file doesn't exist, create it with empty data structure
  148. 10 unless File.exist?(data_file_path)
  149. # Create directory if needed
  150. 10 FileUtils.mkdir_p(@data_dir)
  151. end
  152. end
  153. # Generate new content
  154. 10 content = generate_data_content(view_name, data_properties, onclick_actions)
  155. # Write the updated content
  156. 10 File.write(data_file_path, content)
  157. 10 puts " Updated Data model: #{data_file_path}"
  158. end
  159. 1 def find_existing_data_file(view_name)
  160. # Try exact match first
  161. 12 exact_path = File.join(@data_dir, "#{view_name}Data.kt")
  162. 12 return exact_path if File.exist?(exact_path)
  163. # Try case-insensitive search
  164. 11 Dir.glob(File.join(@data_dir, '*Data.kt')).find do |file|
  165. File.basename(file, '.kt').downcase == "#{view_name}Data".downcase
  166. end
  167. end
  168. 1 def extract_class_name(file_path)
  169. 2 content = File.read(file_path)
  170. 2 if match = content.match(/data\s+class\s+(\w+Data)\s*\(/)
  171. 1 match[1]
  172. else
  173. nil
  174. end
  175. end
  176. 1 def generate_data_content(view_name, data_properties, onclick_actions = [])
  177. 15 content = <<~KOTLIN
  178. package #{@package_name}.data
  179. import androidx.compose.runtime.MutableState
  180. import androidx.compose.runtime.mutableStateOf
  181. import #{@package_name}.viewmodels.#{view_name}ViewModel
  182. KOTLIN
  183. # Add Color import if any property uses Color type
  184. 27 if data_properties.any? { |prop| prop['class'] == 'Color' }
  185. 1 content += "import androidx.compose.ui.graphics.Color\n"
  186. end
  187. 15 content += "\ndata class #{view_name}Data(\n"
  188. 15 if data_properties.empty?
  189. 7 content += " // No data properties defined in JSON\n"
  190. 7 content += " val placeholder: String = \"placeholder\"\n"
  191. else
  192. # Add each property with correct type and default value
  193. 8 data_properties.each_with_index do |prop, index|
  194. 12 name = prop['name']
  195. 12 class_type = map_to_kotlin_type(prop['class'])
  196. 12 default_value = prop['defaultValue']
  197. # If no default value or nil, make it nullable
  198. 12 if default_value.nil? || default_value == 'nil'
  199. 1 content += " var #{name}: #{class_type}? = null"
  200. else
  201. 11 formatted_value = format_default_value(default_value, prop['class'])
  202. 11 content += " var #{name}: #{class_type} = #{formatted_value}"
  203. end
  204. # Add comma if not last property
  205. 12 content += "," if index < data_properties.length - 1
  206. 12 content += "\n"
  207. end
  208. end
  209. 15 content += ") {\n"
  210. # Add companion object with update function
  211. 15 content += " companion object {\n"
  212. 15 content += " // Update properties from map\n"
  213. 15 content += " fun fromMap(map: Map<String, Any>): #{view_name}Data {\n"
  214. 15 content += " return #{view_name}Data(\n"
  215. 15 if !data_properties.empty?
  216. 8 data_properties.each_with_index do |prop, index|
  217. 12 name = prop['name']
  218. 12 class_type = prop['class']
  219. 12 kotlin_type = map_to_kotlin_type(class_type)
  220. # Generate conversion code based on type
  221. 12 content += " #{name} = "
  222. 12 case class_type
  223. when 'String'
  224. 8 content += "map[\"#{name}\"] as? String ?: \"\""
  225. when 'Int'
  226. 1 content += "(map[\"#{name}\"] as? Number)?.toInt() ?: 0"
  227. when 'Double'
  228. content += "(map[\"#{name}\"] as? Number)?.toDouble() ?: 0.0"
  229. when 'Float'
  230. 1 content += "(map[\"#{name}\"] as? Number)?.toFloat() ?: 0f"
  231. when 'Bool', 'Boolean'
  232. 1 content += "map[\"#{name}\"] as? Boolean ?: false"
  233. when 'Color'
  234. 1 content += "map[\"#{name}\"] as? Color ?: Color.Unspecified"
  235. when 'CollectionDataSource'
  236. content += "com.kotlinjsonui.data.CollectionDataSource()"
  237. when /^List<.*>$/
  238. content += "map[\"#{name}\"] as? #{kotlin_type} ?: emptyList()"
  239. when /^Map<.*>$/
  240. content += "map[\"#{name}\"] as? #{kotlin_type} ?: emptyMap()"
  241. else
  242. # For custom types, try to cast directly
  243. content += "map[\"#{name}\"] as? #{kotlin_type}"
  244. end
  245. 12 content += "," if index < data_properties.length - 1
  246. 12 content += "\n"
  247. end
  248. else
  249. 7 content += " placeholder = \"placeholder\"\n"
  250. end
  251. 15 content += " )\n"
  252. 15 content += " }\n"
  253. 15 content += " }\n"
  254. # Add toMap function with viewModel parameter
  255. 15 content += "\n"
  256. 15 content += " // Convert properties to map for runtime use\n"
  257. 15 content += " fun toMap(viewModel: #{view_name}ViewModel? = null): MutableMap<String, Any> {\n"
  258. 15 content += " val map = mutableMapOf<String, Any>()\n"
  259. # Add data properties
  260. 15 if !data_properties.empty?
  261. 8 content += " \n"
  262. 8 content += " // Data properties\n"
  263. 8 data_properties.each do |prop|
  264. 12 name = prop['name']
  265. 12 default_value = prop['defaultValue']
  266. # If it's nullable, check for null
  267. 12 if default_value.nil? || default_value == 'nil'
  268. 1 content += " #{name}?.let { map[\"#{name}\"] = it }\n"
  269. else
  270. 11 content += " map[\"#{name}\"] = #{name}\n"
  271. end
  272. end
  273. end
  274. # Add onclick actions if viewModel is provided
  275. 15 if !onclick_actions.empty?
  276. 2 content += " \n"
  277. 2 content += " // Add onclick action lambdas if viewModel is provided\n"
  278. 2 content += " viewModel?.let { vm ->\n"
  279. 2 onclick_actions.each do |action|
  280. 4 content += " map[\"#{action}\"] = { vm.#{action}() }\n"
  281. end
  282. 2 content += " }\n"
  283. end
  284. 15 if data_properties.empty? && onclick_actions.empty?
  285. 5 content += " // No properties to add\n"
  286. end
  287. 15 content += " \n"
  288. 15 content += " return map\n"
  289. 15 content += " }\n"
  290. 15 content += "}\n"
  291. 15 content
  292. end
  293. 1 def map_to_kotlin_type(json_class)
  294. 34 case json_class
  295. when 'String'
  296. 17 'String'
  297. when 'Int'
  298. 3 'Int'
  299. when 'Double'
  300. 1 'Double'
  301. when 'Float'
  302. 3 'Float'
  303. when 'Bool', 'Boolean'
  304. 4 'Boolean'
  305. when 'CGFloat'
  306. 1 'Float'
  307. when 'Color'
  308. 3 'Color'
  309. when 'CollectionDataSource'
  310. # Use the actual CollectionDataSource type
  311. 1 'com.kotlinjsonui.data.CollectionDataSource'
  312. else
  313. # Return as-is for custom types
  314. 1 json_class
  315. end
  316. end
  317. 1 def format_default_value(value, json_class)
  318. 29 case json_class
  319. when 'String'
  320. # For String class, add quotes
  321. 8 "\"#{value}\""
  322. when 'Bool', 'Boolean'
  323. # Convert to boolean
  324. 4 if value.is_a?(TrueClass) || value.is_a?(FalseClass)
  325. 3 value.to_s
  326. else
  327. 1 value.to_s.downcase == 'true' ? 'true' : 'false'
  328. end
  329. when 'Int'
  330. # Ensure it's an integer
  331. 3 value.to_i.to_s
  332. when 'Double'
  333. # Ensure it's a double
  334. 1 "#{value.to_f}"
  335. when 'Float', 'CGFloat'
  336. # Ensure it's a float with f suffix
  337. 3 "#{value.to_f}f"
  338. when 'Color'
  339. # Handle color values
  340. 5 if value.is_a?(String) && value.start_with?('#')
  341. 2 "Color(android.graphics.Color.parseColor(\"#{value}\"))"
  342. 3 elsif value.is_a?(String) && value.start_with?('Color.')
  343. 1 value # Direct Color reference like Color.Red
  344. 2 elsif value.is_a?(String) && value.downcase.include?('color')
  345. # Map common color names
  346. case value.downcase
  347. when 'red' then 'Color.Red'
  348. when 'green' then 'Color.Green'
  349. when 'blue' then 'Color.Blue'
  350. when 'black' then 'Color.Black'
  351. when 'white' then 'Color.White'
  352. when 'gray', 'grey' then 'Color.Gray'
  353. when 'yellow' then 'Color.Yellow'
  354. when 'cyan' then 'Color.Cyan'
  355. when 'magenta' then 'Color.Magenta'
  356. else 'Color.Unspecified'
  357. end
  358. else
  359. 2 'Color.Unspecified'
  360. end
  361. when 'CollectionDataSource'
  362. # Return the actual default value string or create new instance
  363. 1 if value.is_a?(String) && value == 'CollectionDataSource()'
  364. 1 'com.kotlinjsonui.data.CollectionDataSource()'
  365. else
  366. 'com.kotlinjsonui.data.CollectionDataSource()'
  367. end
  368. when /^List<.*>$/
  369. # Handle generic List types
  370. 2 if value.is_a?(Array) && value.empty?
  371. 1 'emptyList()'
  372. 1 elsif value == '[]' || value == []
  373. 1 'emptyList()'
  374. else
  375. 'emptyList()'
  376. end
  377. when /^Map<.*>$/
  378. # Handle generic Map types
  379. 2 if value.is_a?(Hash) && value.empty?
  380. 1 'emptyMap()'
  381. 1 elsif value == '{}' || value == {} || value == '{}'
  382. 1 'emptyMap()'
  383. else
  384. 'emptyMap()'
  385. end
  386. else
  387. # For all other cases, use value as-is
  388. value
  389. end
  390. end
  391. 1 def to_pascal_case(str)
  392. # Handle various naming patterns
  393. 13 snake = str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
  394. .gsub(/([a-z\d])([A-Z])/, '\1_\2')
  395. .downcase
  396. 13 snake.split(/[_\-]/).map(&:capitalize).join
  397. end
  398. end
  399. end
  400. end

lib/compose/generators/cell_generator.rb

96.77% lines covered

93 relevant lines. 90 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Generators
  9. 1 class CellGenerator
  10. 1 def initialize(name, options = {})
  11. 14 @name = name
  12. 14 @options = options
  13. 14 @config = Core::ConfigManager.load_config
  14. end
  15. 1 def generate
  16. # Parse name for subdirectories
  17. 12 parts = @name.split('/')
  18. 12 cell_name = parts.last
  19. 12 subdirectory = parts[0...-1].join('/') if parts.length > 1
  20. # Keep original PascalCase if provided, otherwise convert
  21. # If the name is already in PascalCase (e.g., ProductCell), keep it
  22. 12 cell_class_name = cell_name
  23. 12 json_file_name = to_snake_case(cell_name)
  24. # Get directories from config
  25. 12 source_dir = @config['source_directory'] || 'src/main'
  26. 12 layouts_dir = @config['layouts_directory'] || 'assets/Layouts'
  27. 12 view_dir = @config['view_directory'] || 'kotlin/com/example/kotlinjsonui/sample/views'
  28. 12 viewmodel_dir = @config['viewmodel_directory'] || 'kotlin/com/example/kotlinjsonui/sample/viewmodels'
  29. 12 data_dir = @config['data_directory'] || 'kotlin/com/example/kotlinjsonui/sample/data'
  30. 12 package_name = @config['package_name'] || 'com.example.kotlinjsonui.sample'
  31. # Create full paths with subdirectory support
  32. # Each cell gets its own directory (using snake_case for Android)
  33. 12 cell_folder_name = to_snake_case(cell_name)
  34. 12 if subdirectory
  35. 1 json_path = File.join(source_dir, layouts_dir, subdirectory)
  36. 1 swift_path = File.join(source_dir, view_dir, subdirectory, cell_folder_name)
  37. 1 viewmodel_path = File.join(source_dir, viewmodel_dir, subdirectory)
  38. 1 data_path = File.join(source_dir, data_dir, subdirectory)
  39. else
  40. 11 json_path = File.join(source_dir, layouts_dir)
  41. 11 swift_path = File.join(source_dir, view_dir, cell_folder_name)
  42. 11 viewmodel_path = File.join(source_dir, viewmodel_dir)
  43. 11 data_path = File.join(source_dir, data_dir)
  44. end
  45. # Create directories if they don't exist
  46. 12 FileUtils.mkdir_p(json_path)
  47. 12 FileUtils.mkdir_p(swift_path)
  48. 12 FileUtils.mkdir_p(viewmodel_path)
  49. 12 FileUtils.mkdir_p(data_path)
  50. # Create JSON file
  51. 12 json_file = File.join(json_path, "#{json_file_name}.json")
  52. 12 create_json_template(json_file, cell_class_name)
  53. # Create Main Cell View file (add View suffix to class name)
  54. 12 main_kotlin_file = File.join(swift_path, "#{cell_class_name}View.kt")
  55. 12 create_main_cell_template(main_kotlin_file, cell_class_name, json_file_name, subdirectory, package_name)
  56. # Create Generated View file
  57. 12 generated_kotlin_file = File.join(swift_path, "#{cell_class_name}GeneratedView.kt")
  58. 12 create_generated_cell_template(generated_kotlin_file, cell_class_name, json_file_name, subdirectory, package_name)
  59. # Create Data file with item property
  60. 12 data_file = File.join(data_path, "#{cell_class_name}Data.kt")
  61. 12 create_cell_data_template(data_file, cell_class_name, package_name)
  62. # Create ViewModel file
  63. 12 viewmodel_file = File.join(viewmodel_path, "#{cell_class_name}ViewModel.kt")
  64. 12 create_cell_viewmodel_template(viewmodel_file, cell_class_name, json_file_name, subdirectory, package_name)
  65. 12 puts "Generated Collection Cell view:"
  66. 12 puts " JSON: #{json_file}"
  67. 12 puts " Main View: #{main_kotlin_file}"
  68. 12 puts " Generated View: #{generated_kotlin_file}"
  69. 12 puts " Data: #{data_file}"
  70. 12 puts " ViewModel: #{viewmodel_file}"
  71. 12 puts ""
  72. 12 puts "Next steps:"
  73. 12 puts " 1. Edit the JSON layout in #{json_file}"
  74. 12 puts " 2. Run 'kjui build' to generate the Compose code"
  75. 12 puts " 3. Use this cell in Collection components with cellClasses: [\"#{cell_class_name}\"]"
  76. end
  77. 1 private
  78. 1 def create_json_template(file_path, class_name)
  79. 12 return if File.exist?(file_path)
  80. json_content = {
  81. 11 "type" => "View",
  82. "orientation" => "horizontal",
  83. "padding" => 12,
  84. "background" => "#F9F9F9",
  85. "cornerRadius" => 6,
  86. "child" => [
  87. {
  88. "type" => "Text",
  89. "text" => "@{item.title}",
  90. "fontSize" => 14,
  91. "weight" => 1
  92. },
  93. {
  94. "type" => "Text",
  95. "text" => "@{item.value}",
  96. "fontSize" => 14,
  97. "fontWeight" => "bold"
  98. }
  99. ]
  100. }
  101. 11 File.write(file_path, JSON.pretty_generate(json_content))
  102. end
  103. 1 def create_main_cell_template(file_path, class_name, json_name, subdirectory, package_name)
  104. 12 return if File.exist?(file_path)
  105. # Calculate relative package path
  106. 12 view_package = if subdirectory
  107. 1 "#{package_name}.views.#{subdirectory.gsub('/', '.')}.#{to_snake_case(class_name)}"
  108. else
  109. 11 "#{package_name}.views.#{to_snake_case(class_name)}"
  110. end
  111. 12 content = <<~KOTLIN
  112. package #{view_package}
  113. import androidx.compose.runtime.Composable
  114. import androidx.compose.ui.Modifier
  115. import #{package_name}.data.#{class_name}Data
  116. import #{package_name}.viewmodels.#{class_name}ViewModel
  117. @Composable
  118. fun #{class_name}View(
  119. data: #{class_name}Data,
  120. viewModel: #{class_name}ViewModel,
  121. modifier: Modifier = Modifier
  122. ) {
  123. // This is a cell view for use in Collection components
  124. // The data parameter contains an 'item' property with the cell's data
  125. #{class_name}GeneratedView(
  126. data = data,
  127. viewModel = viewModel,
  128. modifier = modifier
  129. )
  130. }
  131. KOTLIN
  132. 12 File.write(file_path, content)
  133. end
  134. 1 def create_generated_cell_template(file_path, class_name, json_name, subdirectory, package_name)
  135. 12 return if File.exist?(file_path)
  136. # Calculate relative package path
  137. 12 view_package = if subdirectory
  138. 1 "#{package_name}.views.#{subdirectory.gsub('/', '.')}.#{to_snake_case(class_name)}"
  139. else
  140. 11 "#{package_name}.views.#{to_snake_case(class_name)}"
  141. end
  142. 12 content = <<~KOTLIN
  143. package #{view_package}
  144. import androidx.compose.foundation.background
  145. import androidx.compose.foundation.layout.*
  146. import androidx.compose.material3.*
  147. import androidx.compose.runtime.Composable
  148. import androidx.compose.ui.Alignment
  149. import androidx.compose.ui.Modifier
  150. import androidx.compose.ui.graphics.Color
  151. import androidx.compose.ui.text.font.FontWeight
  152. import androidx.compose.ui.text.style.TextAlign
  153. import androidx.compose.ui.unit.dp
  154. import androidx.compose.ui.unit.sp
  155. import #{package_name}.data.#{class_name}Data
  156. import #{package_name}.viewmodels.#{class_name}ViewModel
  157. import androidx.compose.material3.CircularProgressIndicator
  158. import androidx.compose.foundation.layout.Box
  159. import com.kotlinjsonui.core.DynamicModeManager
  160. import com.kotlinjsonui.core.SafeDynamicView
  161. @Composable
  162. fun #{class_name}GeneratedView(
  163. data: #{class_name}Data,
  164. viewModel: #{class_name}ViewModel,
  165. modifier: Modifier = Modifier
  166. ) {
  167. // Generated Compose code from #{json_name}.json
  168. // This will be updated when you run 'kjui build'
  169. // >>> GENERATED_CODE_START
  170. // Check if Dynamic Mode is active
  171. if (DynamicModeManager.isActive()) {
  172. // Dynamic Mode - use SafeDynamicView for real-time updates
  173. SafeDynamicView(
  174. layoutName = "#{json_name}",
  175. data = data.toMap(viewModel),
  176. modifier = modifier,
  177. fallback = {
  178. // Show error or loading state when dynamic view is not available
  179. Box(
  180. modifier = Modifier.fillMaxSize(),
  181. contentAlignment = Alignment.Center
  182. ) {
  183. Text(
  184. text = "Dynamic view not available",
  185. color = Color.Gray
  186. )
  187. }
  188. },
  189. onError = { error ->
  190. // Log error or show error UI
  191. android.util.Log.e("DynamicView", "Error loading #{json_name}: \\$error")
  192. },
  193. onLoading = {
  194. // Show loading indicator
  195. Box(
  196. modifier = Modifier.fillMaxSize(),
  197. contentAlignment = Alignment.Center
  198. ) {
  199. CircularProgressIndicator()
  200. }
  201. }
  202. ) { jsonContent ->
  203. // Parse and render the dynamic JSON content
  204. // This will be handled by the DynamicView implementation
  205. }
  206. } else {
  207. // Static Mode - use generated code
  208. // TODO: Generated content will appear here when you run 'kjui build'
  209. Box(
  210. modifier = modifier
  211. .fillMaxWidth()
  212. .padding(16.dp)
  213. ) {
  214. Text("Cell content will be generated from #{json_name}.json")
  215. }
  216. }
  217. // >>> GENERATED_CODE_END
  218. }
  219. KOTLIN
  220. 12 File.write(file_path, content)
  221. end
  222. 1 def create_cell_data_template(file_path, class_name, package_name)
  223. 12 return if File.exist?(file_path)
  224. 12 content = <<~KOTLIN
  225. package #{package_name}.data
  226. import androidx.compose.runtime.MutableState
  227. import androidx.compose.runtime.mutableStateOf
  228. import #{package_name}.viewmodels.#{class_name}ViewModel
  229. data class #{class_name}Data(
  230. var item: Map<String, Any> = emptyMap()
  231. ) {
  232. companion object {
  233. // Update properties from map
  234. fun fromMap(map: Map<String, Any>): #{class_name}Data {
  235. return #{class_name}Data(
  236. item = map["item"] as? Map<String, Any> ?: emptyMap()
  237. )
  238. }
  239. }
  240. // Convert properties to map for runtime use
  241. fun toMap(viewModel: #{class_name}ViewModel? = null): MutableMap<String, Any> {
  242. val map = mutableMapOf<String, Any>()
  243. // Data properties
  244. map["item"] = item
  245. return map
  246. }
  247. }
  248. KOTLIN
  249. 12 File.write(file_path, content)
  250. end
  251. 1 def create_cell_viewmodel_template(file_path, class_name, json_name, subdirectory, package_name)
  252. 12 return if File.exist?(file_path)
  253. 12 content = <<~KOTLIN
  254. package #{package_name}.viewmodels
  255. import android.app.Application
  256. import androidx.lifecycle.AndroidViewModel
  257. import androidx.lifecycle.viewModelScope
  258. import androidx.compose.runtime.mutableStateOf
  259. import androidx.compose.runtime.getValue
  260. import androidx.compose.runtime.setValue
  261. import kotlinx.coroutines.launch
  262. import #{package_name}.data.#{class_name}Data
  263. class #{class_name}ViewModel(application: Application) : AndroidViewModel(application) {
  264. // Cell data - managed by parent Collection
  265. var data by mutableStateOf(#{class_name}Data())
  266. private set
  267. // This is a cell view model
  268. // Data is typically provided by the parent Collection component
  269. fun updateData(newData: #{class_name}Data) {
  270. data = newData
  271. }
  272. fun updateItem(item: Map<String, Any>) {
  273. data = data.copy(item = item)
  274. }
  275. }
  276. KOTLIN
  277. 12 File.write(file_path, content)
  278. end
  279. 1 def to_pascal_case(str)
  280. str.split(/[_\-]/).map(&:capitalize).join
  281. end
  282. 1 def to_snake_case(str)
  283. 48 str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
  284. .gsub(/([a-z\d])([A-Z])/, '\1_\2')
  285. .downcase
  286. end
  287. 1 def to_camel_case(str)
  288. pascal = to_pascal_case(str)
  289. pascal[0].downcase + pascal[1..-1]
  290. end
  291. end
  292. end
  293. end
  294. end

lib/compose/generators/converter_generator.rb

79.03% lines covered

124 relevant lines. 98 lines covered and 26 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'fileutils'
  3. 1 require 'json'
  4. 1 require_relative '../../core/logger'
  5. 1 require_relative 'kotlin_component_generator'
  6. 1 require_relative 'dynamic_component_generator'
  7. 1 module KjuiTools
  8. 1 module Compose
  9. 1 module Generators
  10. 1 class ConverterGenerator
  11. 1 def initialize(name, options = {})
  12. 19 @name = name
  13. # Keep original PascalCase name for component
  14. 19 @component_pascal_case = name # e.g., MyTestCard
  15. 19 @component_snake_case = to_snake_case(name) # e.g., my_test_card
  16. 19 @class_name = name + "Component" # e.g., MyTestCardComponent
  17. 19 @options = options
  18. 19 @logger = Core::Logger
  19. end
  20. 1 def generate
  21. 2 @logger.info "Generating custom converter: #{@class_name}"
  22. # Create converter file for static generation
  23. 2 create_converter_file
  24. # Update component mappings file
  25. 2 update_mappings_file
  26. # Create Kotlin component file using separate generator
  27. 2 kotlin_generator = KotlinComponentGenerator.new(@name, @options)
  28. 2 kotlin_generator.generate
  29. # Generate dynamic component file
  30. 2 dynamic_generator = DynamicComponentGenerator.new(@name, @options)
  31. 2 dynamic_generator.generate
  32. # Create or update DynamicComponentInitializer files
  33. 2 create_dynamic_initializers
  34. 2 @logger.success "Successfully generated converter: #{@class_name}"
  35. 2 @logger.info "Converter file created at: kjui_tools/lib/compose/components/extensions/#{@component_snake_case}_component.rb"
  36. 2 @logger.info "Mappings file updated with '#{@component_pascal_case}' => '#{@class_name}'"
  37. end
  38. 1 private
  39. 1 def create_converter_file
  40. # Get the path relative to this generator file
  41. 2 generator_dir = File.dirname(__FILE__)
  42. # Go up to lib/compose/components/extensions
  43. 2 extensions_dir = File.join(generator_dir, '..', 'components', 'extensions')
  44. 2 extensions_dir = File.expand_path(extensions_dir)
  45. 2 FileUtils.mkdir_p(extensions_dir)
  46. 2 file_path = File.join(extensions_dir, "#{@component_snake_case}_component.rb")
  47. 2 if File.exist?(file_path)
  48. @logger.warn "Converter file already exists: #{file_path}"
  49. print "Overwrite? (y/n): "
  50. response = gets.chomp.downcase
  51. return unless response == 'y'
  52. end
  53. 2 File.write(file_path, converter_template)
  54. 2 @logger.info "Created converter file: #{file_path}"
  55. end
  56. 1 def update_mappings_file
  57. # Get the path relative to this generator file
  58. 2 generator_dir = File.dirname(__FILE__)
  59. 2 mappings_file = File.join(generator_dir, '..', 'components', 'extensions', 'component_mappings.rb')
  60. 2 mappings_file = File.expand_path(mappings_file)
  61. # Create new mappings file if it doesn't exist
  62. 2 if !File.exist?(mappings_file)
  63. 2 create_initial_mappings_file
  64. 2 return
  65. end
  66. # Read existing mappings
  67. content = File.read(mappings_file)
  68. # Check if mapping already exists
  69. if content.include?("'#{@component_pascal_case}' =>")
  70. @logger.warn "Mapping for '#{@component_pascal_case}' already exists in component_mappings.rb"
  71. return
  72. end
  73. # Add require statement if not present
  74. require_line = "require_relative '#{@component_snake_case}_component'"
  75. unless content.include?(require_line)
  76. # Add require after other requires or at the beginning of the module
  77. if content =~ /^require_relative/
  78. # Add after the last require
  79. content.sub!(/^((?:require_relative.*\n)+)/) do
  80. "#{$1}#{require_line}\n"
  81. end
  82. else
  83. # Add before the module declaration
  84. content.sub!(/^(# Auto-generated.*\n)\n/) do
  85. "#{$1}\n#{require_line}\n\n"
  86. end
  87. end
  88. end
  89. # Add new mapping
  90. new_mapping = " '#{@component_pascal_case}' => #{@class_name},"
  91. # Insert the new mapping before the closing brace of COMPONENT_MAPPINGS
  92. content.sub!(/(COMPONENT_MAPPINGS = \{.*?)(,?)(\s*)( \}\.freeze)/m) do
  93. existing_mappings = $1
  94. last_comma = $2
  95. whitespace = $3
  96. closing = $4
  97. # If there are existing mappings, add the new one with proper formatting
  98. if existing_mappings =~ /=>/
  99. # Ensure the last existing mapping has a comma, then add the new mapping
  100. "#{existing_mappings},\n#{new_mapping}\n#{closing}"
  101. else
  102. # First mapping
  103. "#{existing_mappings}\n#{new_mapping}\n#{closing}"
  104. end
  105. end
  106. File.write(mappings_file, content)
  107. @logger.info "Updated component_mappings.rb with new mapping"
  108. end
  109. 1 def create_initial_mappings_file
  110. # Get the path relative to this generator file
  111. 3 generator_dir = File.dirname(__FILE__)
  112. 3 extensions_dir = File.join(generator_dir, '..', 'components', 'extensions')
  113. 3 extensions_dir = File.expand_path(extensions_dir)
  114. 3 FileUtils.mkdir_p(extensions_dir)
  115. 3 mappings_file = File.join(extensions_dir, 'component_mappings.rb')
  116. 3 content = <<~RUBY
  117. # frozen_string_literal: true
  118. # This file maps custom component types to their converter classes
  119. # Auto-generated by kjui g converter command
  120. require_relative '#{@component_snake_case}_component'
  121. module KjuiTools
  122. module Compose
  123. module Components
  124. module Extensions
  125. COMPONENT_MAPPINGS = {
  126. '#{@component_pascal_case}' => #{@class_name},
  127. }.freeze
  128. end
  129. end
  130. end
  131. end
  132. RUBY
  133. 3 File.write(mappings_file, content)
  134. 3 @logger.info "Created component_mappings.rb with initial mapping"
  135. end
  136. 1 def converter_template
  137. 6 <<~RUBY
  138. # frozen_string_literal: true
  139. require_relative '../../helpers/modifier_builder'
  140. module KjuiTools
  141. module Compose
  142. module Components
  143. module Extensions
  144. class #{@class_name}
  145. def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
  146. required_imports&.add(:box)
  147. # Check if this is a container component
  148. children = json_data['children'] || json_data['child']
  149. is_container = children && children.is_a?(Array) && !children.empty?
  150. # Collect parameters
  151. params = []
  152. # Helper method to format values
  153. format_value = lambda do |value, type|
  154. case type.downcase
  155. when 'string', 'text'
  156. # Use ResourceResolver to process strings (checks for resources)
  157. Helpers::ResourceResolver.process_text(value, required_imports)
  158. when 'int', 'integer', 'float', 'double', 'bool', 'boolean'
  159. value.to_s
  160. when 'color'
  161. # Use ResourceResolver to process colors
  162. Helpers::ResourceResolver.process_color(value, required_imports)
  163. else
  164. value.to_s
  165. end
  166. end
  167. #{generate_parameter_collection}
  168. # Build modifiers
  169. modifiers = []
  170. modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
  171. modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
  172. modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
  173. modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
  174. if is_container
  175. # Container component with children
  176. code = indent("#{@component_pascal_case}(", depth)
  177. if !params.empty?
  178. params.each_with_index do |param, index|
  179. separator = index == params.length - 1 ? '' : ','
  180. code += "\\n" + indent("\#{param}\#{separator}", depth + 1)
  181. end
  182. end
  183. if !modifiers.empty?
  184. modifier_str = Helpers::ModifierBuilder.format(modifiers, depth)
  185. code += (params.empty? ? modifier_str : "," + modifier_str)
  186. end
  187. code += "\\n" + indent(") {", depth)
  188. # Process children - return with metadata for ComposeBuilder to handle
  189. return {
  190. code: code,
  191. children: children,
  192. closing: "\\n" + indent("}", depth)
  193. }
  194. else
  195. # Non-container component
  196. if params.empty? && modifiers.empty?
  197. code = indent("#{@component_pascal_case}()", depth)
  198. else
  199. code = indent("#{@component_pascal_case}(", depth)
  200. if !params.empty?
  201. params.each_with_index do |param, index|
  202. separator = index == params.length - 1 ? '' : ','
  203. code += "\\n" + indent("\#{param}\#{separator}", depth + 1)
  204. end
  205. end
  206. if !modifiers.empty?
  207. modifier_str = Helpers::ModifierBuilder.format(modifiers, depth)
  208. code += (params.empty? ? modifier_str : "," + modifier_str)
  209. end
  210. code += "\\n" + indent(")", depth)
  211. end
  212. end
  213. code
  214. end
  215. private
  216. def self.indent(text, level)
  217. return text if level == 0
  218. spaces = ' ' * level
  219. text.split("\\n").map { |line|
  220. line.empty? ? line : spaces + line
  221. }.join("\\n")
  222. end
  223. end
  224. end
  225. end
  226. end
  227. end
  228. RUBY
  229. end
  230. 1 def generate_parameter_collection
  231. 10 return "" if !@options[:attributes] || @options[:attributes].empty?
  232. 5 lines = []
  233. 5 @options[:attributes].each do |key, type|
  234. # Check if this is a binding property (starts with @)
  235. 9 is_binding = key.start_with?('@')
  236. 9 actual_key = is_binding ? key[1..-1] : key
  237. 9 lines << " if json_data['#{actual_key}']"
  238. 9 lines << " value = json_data['#{actual_key}']"
  239. 9 lines << " if value.is_a?(String) && value.match?(/@\\{([^}]+)\\}/)"
  240. 9 lines << " # Handle binding"
  241. 9 lines << " prop_name = value[2..-2]"
  242. 9 lines << " params << \"#{actual_key} = data.\#{prop_name}\""
  243. 9 lines << " else"
  244. 9 lines << " # Handle static value"
  245. 9 lines << " formatted_value = format_value.call(value, '#{type}')"
  246. 9 lines << " params << \"#{actual_key} = \#{formatted_value}\" if formatted_value"
  247. 9 lines << " end"
  248. 9 lines << " end"
  249. end
  250. 5 lines.join("\n")
  251. end
  252. 1 def to_snake_case(str)
  253. 23 str.gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
  254. .gsub(/([a-z\d])([A-Z])/,'\1_\2')
  255. .downcase
  256. end
  257. 1 def create_dynamic_initializers
  258. 2 config = Core::ConfigManager.load_config
  259. 2 base_path = config['_config_dir'] || Dir.pwd
  260. 2 source_directory = config['source_directory'] || 'src/main'
  261. 2 package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
  262. # Create debug version
  263. 2 debug_dir = File.join(
  264. base_path,
  265. source_directory.gsub('main', 'debug'),
  266. 'kotlin',
  267. package_name.gsub('.', '/')
  268. )
  269. 2 FileUtils.mkdir_p(debug_dir)
  270. 2 debug_file = File.join(debug_dir, 'DynamicComponentInitializer.kt')
  271. # Only create if it doesn't exist yet
  272. 2 if !File.exist?(debug_file)
  273. 2 File.write(debug_file, generate_debug_initializer_content(package_name))
  274. 2 @logger.info "Created DynamicComponentInitializer (debug)"
  275. end
  276. # Create release version
  277. 2 release_dir = File.join(
  278. base_path,
  279. source_directory.gsub('main', 'release'),
  280. 'kotlin',
  281. package_name.gsub('.', '/')
  282. )
  283. 2 FileUtils.mkdir_p(release_dir)
  284. 2 release_file = File.join(release_dir, 'DynamicComponentInitializer.kt')
  285. # Only create if it doesn't exist yet
  286. 2 if !File.exist?(release_file)
  287. 2 File.write(release_file, generate_release_initializer_content(package_name))
  288. 2 @logger.info "Created DynamicComponentInitializer (release)"
  289. end
  290. end
  291. 1 def generate_debug_initializer_content(package_name)
  292. 3 <<~KOTLIN
  293. package #{package_name}
  294. import androidx.compose.runtime.Composable
  295. import com.google.gson.JsonObject
  296. import com.kotlinjsonui.core.Configuration
  297. import #{package_name}.dynamic.DynamicComponentRegistry
  298. /**
  299. * Debug-only initializer for custom components in dynamic mode
  300. * Auto-generated by kjui converter generator
  301. */
  302. object DynamicComponentInitializer {
  303. /**
  304. * Register custom component handler for dynamic mode
  305. * This is only available in debug builds where DynamicComponentRegistry exists
  306. */
  307. fun initialize() {
  308. Configuration.customComponentHandler = { type, json, data ->
  309. DynamicComponentRegistry.createCustomComponent(type, json, data)
  310. }
  311. }
  312. }
  313. KOTLIN
  314. end
  315. 1 def generate_release_initializer_content(package_name)
  316. 3 <<~KOTLIN
  317. package #{package_name}
  318. /**
  319. * Release version of DynamicComponentInitializer (no-op)
  320. * Auto-generated by kjui converter generator
  321. */
  322. object DynamicComponentInitializer {
  323. /**
  324. * No-op in release builds
  325. */
  326. fun initialize() {
  327. // Dynamic component registry is not available in release builds
  328. }
  329. }
  330. KOTLIN
  331. end
  332. end
  333. end
  334. end
  335. end

lib/compose/generators/dynamic_component_generator.rb

64.9% lines covered

151 relevant lines. 98 lines covered and 53 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'fileutils'
  3. 1 require_relative '../../core/logger'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Generators
  9. 1 class DynamicComponentGenerator
  10. 1 def initialize(name, options = {})
  11. 37 @name = name
  12. 37 @component_name = name # PascalCase name
  13. 37 @class_name = "Dynamic#{name}Component"
  14. 37 @options = options
  15. 37 @logger = Core::Logger
  16. end
  17. 1 def generate
  18. create_dynamic_component_file
  19. update_dynamic_registry
  20. end
  21. 1 private
  22. 1 def create_dynamic_component_file
  23. config = Core::ConfigManager.load_config
  24. # Use config directory if available (where kjui.config.json was found)
  25. base_path = config['_config_dir'] || Dir.pwd
  26. source_directory = config['source_directory'] || 'src/main'
  27. package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
  28. # Create dynamic components directory in debug source set
  29. dynamic_dir = File.join(
  30. base_path,
  31. source_directory.gsub('main', 'debug'), # Replace main with debug
  32. 'kotlin',
  33. package_name.gsub('.', '/'),
  34. 'dynamic/components/extensions'
  35. )
  36. FileUtils.mkdir_p(dynamic_dir)
  37. file_path = File.join(dynamic_dir, "#{@class_name}.kt")
  38. if File.exist?(file_path)
  39. @logger.warn "Dynamic component file already exists: #{file_path}"
  40. print "Overwrite? (y/n): "
  41. response = gets.chomp.downcase
  42. return unless response == 'y'
  43. end
  44. File.write(file_path, dynamic_template)
  45. @logger.info "Created dynamic component file: #{file_path}"
  46. end
  47. 1 def update_dynamic_registry
  48. config = Core::ConfigManager.load_config
  49. # Use config directory if available (where kjui.config.json was found)
  50. base_path = config['_config_dir'] || Dir.pwd
  51. source_directory = config['source_directory'] || 'src/main'
  52. package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
  53. registry_file = File.join(
  54. base_path,
  55. source_directory.gsub('main', 'debug'), # Replace main with debug
  56. 'kotlin',
  57. package_name.gsub('.', '/'),
  58. 'dynamic/DynamicComponentRegistry.kt'
  59. )
  60. if !File.exist?(registry_file)
  61. create_initial_registry
  62. return
  63. end
  64. # Read existing registry
  65. content = File.read(registry_file)
  66. # Check if component already registered
  67. if content.include?("\"#{@component_name}\"")
  68. @logger.warn "Component '#{@component_name}' already registered in DynamicComponentRegistry"
  69. return
  70. end
  71. # Add new registration with proper indentation
  72. new_registration = <<-REGISTRATION.chomp
  73. "#{@component_name}" -> {
  74. #{@class_name}.create(json, data)
  75. true
  76. }
  77. REGISTRATION
  78. # Insert before the else statement in when block
  79. content.sub!(/(when \(type\) \{.*?)(\n else)/m) do
  80. existing = $1
  81. else_clause = $2
  82. "#{existing}\n#{new_registration}#{else_clause}"
  83. end
  84. # Add import if not present
  85. config = Core::ConfigManager.load_config
  86. package_name = config['package_name'] || 'com.example.kotlinjsonui.sample'
  87. import_line = "import #{package_name}.dynamic.components.extensions.#{@class_name}"
  88. unless content.include?(import_line)
  89. # Add import after the last import line
  90. content.sub!(/(import .+\n)(\n)/) do
  91. "#{$1}#{import_line}\n#{$2}"
  92. end
  93. end
  94. File.write(registry_file, content)
  95. @logger.info "Updated DynamicComponentRegistry with new component"
  96. end
  97. 1 def create_initial_registry
  98. config = Core::ConfigManager.load_config
  99. # Use config directory if available (where kjui.config.json was found)
  100. base_path = config['_config_dir'] || Dir.pwd
  101. source_directory = config['source_directory'] || 'src/main'
  102. package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
  103. registry_dir = File.join(
  104. base_path,
  105. source_directory.gsub('main', 'debug'), # Replace main with debug
  106. 'kotlin',
  107. package_name.gsub('.', '/'),
  108. 'dynamic'
  109. )
  110. FileUtils.mkdir_p(registry_dir)
  111. registry_file = File.join(registry_dir, 'DynamicComponentRegistry.kt')
  112. config = Core::ConfigManager.load_config
  113. package_name = config['package_name'] || 'com.example.kotlinjsonui.sample'
  114. content = <<~KOTLIN
  115. package #{package_name}.dynamic
  116. import androidx.compose.runtime.Composable
  117. import com.google.gson.JsonObject
  118. import #{package_name}.dynamic.components.extensions.#{@class_name}
  119. /**
  120. * Registry for dynamic custom components
  121. * Auto-generated by kjui converter generator
  122. */
  123. object DynamicComponentRegistry {
  124. @Composable
  125. fun createCustomComponent(
  126. type: String,
  127. json: JsonObject,
  128. data: Map<String, Any>
  129. ): Boolean {
  130. return when (type) {
  131. "#{@component_name}" -> {
  132. #{@class_name}.create(json, data)
  133. true
  134. }
  135. else -> false
  136. }
  137. }
  138. }
  139. KOTLIN
  140. File.write(registry_file, content)
  141. @logger.info "Created DynamicComponentRegistry with initial component"
  142. end
  143. 1 def dynamic_template
  144. 2 config = Core::ConfigManager.load_config
  145. 2 package_name = config['package_name'] || 'com.example.kotlinjsonui.sample'
  146. # Determine if this is a container component
  147. 2 is_container = @options[:is_container]
  148. 2 <<~KOTLIN
  149. package #{package_name}.dynamic.components.extensions
  150. import androidx.compose.runtime.Composable
  151. import androidx.compose.ui.Modifier
  152. import com.google.gson.JsonObject
  153. import com.google.gson.JsonElement
  154. #{generate_dynamic_imports}
  155. import com.kotlinjsonui.dynamic.helpers.ModifierBuilder
  156. import #{package_name}.extensions.#{@component_name}
  157. /**
  158. * Dynamic wrapper for #{@component_name} component
  159. * Auto-generated by kjui converter generator
  160. */
  161. object #{@class_name} {
  162. @Composable
  163. fun create(
  164. json: JsonObject,
  165. data: Map<String, Any> = emptyMap()
  166. ) {
  167. // Parse attributes
  168. #{generate_dynamic_parameter_parsing}
  169. // Build modifier
  170. val modifier = ModifierBuilder.buildModifier(json)
  171. #{if is_container
  172. 1 "// Call the custom component with children\n" +
  173. " #{@component_name}(\n" +
  174. generate_component_parameters +
  175. " modifier = modifier\n" +
  176. " ) {\n" +
  177. " // Process children\n" +
  178. " val children = json.get(\"children\")?.asJsonArray ?: json.get(\"child\")?.asJsonArray\n" +
  179. " children?.forEach { childJson ->\n" +
  180. " if (childJson.isJsonObject) {\n" +
  181. " com.kotlinjsonui.dynamic.DynamicView(\n" +
  182. " json = childJson.asJsonObject,\n" +
  183. " data = data\n" +
  184. " )\n" +
  185. " }\n" +
  186. " }\n" +
  187. " }"
  188. else
  189. 1 "// Call the custom component\n" +
  190. " #{@component_name}(\n" +
  191. generate_component_parameters +
  192. " modifier = modifier\n" +
  193. " )"
  194. end}
  195. }
  196. #{generate_helper_methods}
  197. }
  198. KOTLIN
  199. end
  200. 1 def generate_dynamic_imports
  201. 6 return "" if !@options[:attributes] || @options[:attributes].empty?
  202. 3 imports = []
  203. 3 @options[:attributes].each do |key, type|
  204. 3 case type.downcase
  205. when 'alignment'
  206. 1 imports << "import androidx.compose.ui.Alignment"
  207. when 'text', 'string'
  208. 1 imports << "import androidx.compose.ui.text.style.TextAlign"
  209. when 'color'
  210. 1 imports << "import androidx.compose.ui.graphics.Color"
  211. end
  212. end
  213. 3 imports.uniq.join("\n")
  214. end
  215. 1 def generate_attribute_docs
  216. 3 return " * - child/children: Array of child components" if !@options[:attributes] || @options[:attributes].empty?
  217. 2 docs = [" * - child/children: Array of child components"]
  218. 2 @options[:attributes].each do |key, type|
  219. 2 is_binding = key.start_with?('@')
  220. 2 actual_key = is_binding ? key[1..-1] : key
  221. 2 binding_note = is_binding ? " (supports @{binding})" : ""
  222. 2 docs << " * - #{actual_key}: #{type}#{binding_note}"
  223. end
  224. 2 docs.join("\n")
  225. end
  226. 1 def generate_dynamic_parameter_parsing
  227. 4 return "" if !@options[:attributes] || @options[:attributes].empty?
  228. 1 lines = []
  229. 1 @options[:attributes].each do |key, type|
  230. 1 is_binding = key.start_with?('@')
  231. 1 actual_key = is_binding ? key[1..-1] : key
  232. 1 method_name = get_parser_method_name(type)
  233. 1 lines << " val #{actual_key} = #{method_name}(json.get(\"#{actual_key}\"), data)"
  234. end
  235. 1 lines.join("\n")
  236. end
  237. 1 def get_parser_method_name(type)
  238. 10 case type.downcase
  239. when 'string', 'text'
  240. 3 'parseString'
  241. when 'int', 'integer'
  242. 2 'parseInt'
  243. when 'bool', 'boolean'
  244. 2 'parseBoolean'
  245. when 'color'
  246. 1 'parseColor'
  247. when 'float', 'double'
  248. 1 'parseFloat'
  249. else
  250. 1 'parseString'
  251. end
  252. end
  253. 1 def generate_component_parameters
  254. 4 return "" if !@options[:attributes] || @options[:attributes].empty?
  255. 1 lines = []
  256. 1 @options[:attributes].each do |key, type|
  257. 1 is_binding = key.start_with?('@')
  258. 1 actual_key = is_binding ? key[1..-1] : key
  259. # Generate parameter with null safety
  260. 1 lines << " #{actual_key} = #{actual_key} ?: #{get_default_value(type)},"
  261. end
  262. 1 lines.join("\n") + "\n"
  263. end
  264. 1 def get_default_value(type)
  265. 7 case type.downcase
  266. when 'string', 'text'
  267. 2 '""'
  268. when 'int', 'integer'
  269. 1 '0'
  270. when 'bool', 'boolean'
  271. 1 'false'
  272. when 'float', 'double'
  273. 1 '0.0'
  274. when 'color'
  275. 1 'androidx.compose.ui.graphics.Color.Unspecified'
  276. else
  277. 1 'null'
  278. end
  279. end
  280. 1 def generate_helper_methods
  281. 7 return "" if !@options[:attributes] || @options[:attributes].empty?
  282. 4 methods = []
  283. 4 types_added = []
  284. 4 @options[:attributes].each do |key, type|
  285. 4 next if types_added.include?(type.downcase)
  286. 4 types_added << type.downcase
  287. 4 case type.downcase
  288. when 'string', 'text'
  289. 1 methods << string_parser_method
  290. when 'int', 'integer'
  291. 1 methods << int_parser_method
  292. when 'bool', 'boolean'
  293. 1 methods << bool_parser_method
  294. when 'color'
  295. 1 methods << color_parser_method
  296. end
  297. end
  298. 4 methods.join("\n\n")
  299. end
  300. 1 def string_parser_method
  301. 1 <<~KOTLIN
  302. private fun parseString(element: com.google.gson.JsonElement?, data: Map<String, Any>): String? {
  303. if (element == null || element.isJsonNull) return null
  304. val value = element.asString
  305. // Check for binding
  306. if (value.startsWith("@{") && value.endsWith("}")) {
  307. val propertyName = value.substring(2, value.length - 1)
  308. return data[propertyName]?.toString()
  309. }
  310. return value
  311. }
  312. KOTLIN
  313. end
  314. 1 def int_parser_method
  315. 1 <<~KOTLIN
  316. private fun parseInt(element: com.google.gson.JsonElement?, data: Map<String, Any>): Int? {
  317. if (element == null || element.isJsonNull) return null
  318. if (element.isJsonPrimitive) {
  319. val primitive = element.asJsonPrimitive
  320. if (primitive.isNumber) {
  321. return primitive.asInt
  322. } else if (primitive.isString) {
  323. val value = primitive.asString
  324. // Check for binding
  325. if (value.startsWith("@{") && value.endsWith("}")) {
  326. val propertyName = value.substring(2, value.length - 1)
  327. return (data[propertyName] as? Number)?.toInt()
  328. }
  329. return value.toIntOrNull()
  330. }
  331. }
  332. return null
  333. }
  334. KOTLIN
  335. end
  336. 1 def bool_parser_method
  337. 1 <<~KOTLIN
  338. private fun parseBoolean(element: com.google.gson.JsonElement?, data: Map<String, Any>): Boolean? {
  339. if (element == null || element.isJsonNull) return null
  340. if (element.isJsonPrimitive) {
  341. val primitive = element.asJsonPrimitive
  342. if (primitive.isBoolean) {
  343. return primitive.asBoolean
  344. } else if (primitive.isString) {
  345. val value = primitive.asString
  346. // Check for binding
  347. if (value.startsWith("@{") && value.endsWith("}")) {
  348. val propertyName = value.substring(2, value.length - 1)
  349. return data[propertyName] as? Boolean
  350. }
  351. return value.toBooleanStrictOrNull()
  352. }
  353. }
  354. return null
  355. }
  356. KOTLIN
  357. end
  358. 1 def color_parser_method
  359. 1 <<~KOTLIN
  360. private fun parseColor(element: com.google.gson.JsonElement?, data: Map<String, Any>): Color? {
  361. if (element == null || element.isJsonNull) return null
  362. if (element.isJsonPrimitive && element.asJsonPrimitive.isString) {
  363. val value = element.asString
  364. // Check for binding
  365. if (value.startsWith("@{") && value.endsWith("}")) {
  366. val propertyName = value.substring(2, value.length - 1)
  367. val boundValue = data[propertyName]?.toString()
  368. return boundValue?.let { parseColorString(it) }
  369. }
  370. return parseColorString(value)
  371. }
  372. return null
  373. }
  374. private fun parseColorString(value: String): Color? {
  375. return if (value.startsWith("#")) {
  376. try {
  377. Color(android.graphics.Color.parseColor(value))
  378. } catch (e: Exception) {
  379. null
  380. }
  381. } else {
  382. null
  383. }
  384. }
  385. KOTLIN
  386. end
  387. end
  388. end
  389. end
  390. end

lib/compose/generators/kotlin_component_generator.rb

83.64% lines covered

110 relevant lines. 92 lines covered and 18 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'fileutils'
  3. 1 require_relative '../../core/logger'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Generators
  9. 1 class KotlinComponentGenerator
  10. 1 def initialize(name, options = {})
  11. 40 @name = name
  12. 40 @component_name = name # PascalCase name
  13. 40 @package_name = get_package_name
  14. 40 @options = options
  15. 40 @logger = Core::Logger
  16. end
  17. 1 def generate
  18. create_kotlin_file
  19. end
  20. 1 private
  21. 1 def create_kotlin_file
  22. config = Core::ConfigManager.load_config
  23. # Use config directory if available (where kjui.config.json was found)
  24. base_path = config['_config_dir'] || Dir.pwd
  25. source_directory = config['source_directory'] || 'src/main'
  26. package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
  27. # Get extension directory from config
  28. extension_directory = config['extension_directory'] || "kotlin/#{package_name.gsub('.', '/')}/extensions"
  29. # Build extension directory path
  30. extension_dir = File.join(
  31. base_path,
  32. source_directory,
  33. extension_directory
  34. )
  35. FileUtils.mkdir_p(extension_dir)
  36. kotlin_file_path = File.join(extension_dir, "#{@component_name}.kt")
  37. if File.exist?(kotlin_file_path)
  38. @logger.warn "Kotlin file already exists: #{kotlin_file_path}"
  39. print "Overwrite? (y/n): "
  40. response = gets.chomp.downcase
  41. return unless response == 'y'
  42. end
  43. File.write(kotlin_file_path, kotlin_template)
  44. @logger.info "Created Kotlin file: #{kotlin_file_path}"
  45. end
  46. 1 def get_package_name
  47. 41 config = Core::ConfigManager.load_config
  48. 41 base_package = config['package_name'] || 'com.example.kotlinjsonui.sample'
  49. 41 "#{base_package}.extensions"
  50. end
  51. 1 def kotlin_template
  52. 2 if @options[:is_container] != false
  53. 1 container_template
  54. else
  55. 1 non_container_template
  56. end
  57. end
  58. 1 def container_template
  59. 3 imports = generate_kotlin_imports
  60. 3 params = generate_kotlin_parameters
  61. 3 template = <<~KOTLIN
  62. package #{@package_name}
  63. import androidx.compose.foundation.layout.Box
  64. import androidx.compose.foundation.layout.BoxScope
  65. import androidx.compose.runtime.Composable
  66. import androidx.compose.ui.Modifier
  67. KOTLIN
  68. 3 template += imports + "\n" if !imports.empty?
  69. 3 template += "\n"
  70. 3 template += <<~KOTLIN
  71. /**
  72. * Custom #{@component_name} component
  73. * Generated by kjui converter generator
  74. *
  75. * Regenerate with:
  76. * kjui g converter #{@component_name} --container#{format_attributes_for_command}
  77. */
  78. @Composable
  79. fun #{@component_name}(
  80. KOTLIN
  81. 3 if !params.empty?
  82. template += params
  83. end
  84. 3 template += <<~KOTLIN
  85. modifier: Modifier = Modifier,
  86. content: @Composable BoxScope.() -> Unit
  87. ) {
  88. Box(
  89. modifier = modifier
  90. ) {
  91. // Custom container implementation
  92. content()
  93. }
  94. }
  95. KOTLIN
  96. 3 template
  97. end
  98. 1 def non_container_template
  99. 2 imports = generate_kotlin_imports
  100. 2 params = generate_kotlin_parameters
  101. 2 template = <<~KOTLIN
  102. package #{@package_name}
  103. import androidx.compose.foundation.layout.Box
  104. import androidx.compose.runtime.Composable
  105. import androidx.compose.ui.Modifier
  106. KOTLIN
  107. 2 template += imports + "\n" if !imports.empty?
  108. 2 template += "\n"
  109. 2 template += <<~KOTLIN
  110. /**
  111. * Custom #{@component_name} component
  112. * Generated by kjui converter generator
  113. *
  114. * Regenerate with:
  115. * kjui g converter #{@component_name} --no-container#{format_attributes_for_command}
  116. */
  117. @Composable
  118. fun #{@component_name}(
  119. KOTLIN
  120. 2 if !params.empty?
  121. template += params
  122. end
  123. 2 template += <<~KOTLIN
  124. modifier: Modifier = Modifier
  125. ) {
  126. // TODO: Implement your custom component
  127. Box(modifier = modifier) {
  128. // Component content
  129. }
  130. }
  131. KOTLIN
  132. 2 template
  133. end
  134. 1 def generate_kotlin_imports
  135. 9 return "" if !@options[:attributes] || @options[:attributes].empty?
  136. 3 imports = []
  137. 3 @options[:attributes].each do |key, type|
  138. 3 case type.downcase
  139. when 'color'
  140. 1 imports << "import androidx.compose.ui.graphics.Color"
  141. when 'dp', 'size'
  142. 1 imports << "import androidx.compose.ui.unit.dp"
  143. 1 imports << "import androidx.compose.ui.unit.Dp"
  144. when 'alignment'
  145. 1 imports << "import androidx.compose.ui.Alignment"
  146. when 'text', 'string'
  147. # No special import needed
  148. when 'int', 'float', 'double'
  149. # No special import needed
  150. when 'boolean', 'bool'
  151. # No special import needed
  152. end
  153. end
  154. 3 imports.uniq.join("\n")
  155. end
  156. 1 def generate_kotlin_parameters
  157. 7 return "" if !@options[:attributes] || @options[:attributes].empty?
  158. 1 params = []
  159. 1 @options[:attributes].each do |key, type|
  160. 1 is_binding = key.start_with?('@')
  161. 1 actual_key = is_binding ? key[1..-1] : key
  162. 1 kotlin_type = map_type_to_kotlin(type)
  163. 1 default_value = get_default_value(type)
  164. 1 params << " #{actual_key}: #{kotlin_type}#{default_value},"
  165. end
  166. 1 params.join("\n") + "\n"
  167. end
  168. 1 def map_type_to_kotlin(type)
  169. 14 case type.downcase
  170. when 'string', 'text'
  171. 3 'String'
  172. when 'int', 'integer'
  173. 2 'Int'
  174. when 'float'
  175. 1 'Float'
  176. when 'double'
  177. 1 'Double'
  178. when 'bool', 'boolean'
  179. 2 'Boolean'
  180. when 'color'
  181. 1 'Color'
  182. when 'dp', 'size'
  183. 2 'Dp'
  184. when 'alignment'
  185. 1 'Alignment'
  186. else
  187. 1 'Any'
  188. end
  189. end
  190. 1 def get_default_value(type)
  191. 10 case type.downcase
  192. when 'string', 'text'
  193. 2 ' = ""'
  194. when 'int', 'integer'
  195. 1 ' = 0'
  196. when 'float'
  197. 1 ' = 0f'
  198. when 'double'
  199. 1 ' = 0.0'
  200. when 'bool', 'boolean'
  201. 1 ' = false'
  202. when 'color'
  203. 1 ' = Color.Unspecified'
  204. when 'dp', 'size'
  205. 1 ' = 0.dp'
  206. when 'alignment'
  207. 1 ' = Alignment.TopStart'
  208. else
  209. 1 ' = null'
  210. end
  211. end
  212. 1 def format_attributes_for_command
  213. 7 return "" if !@options[:attributes] || @options[:attributes].empty?
  214. 1 attrs = @options[:attributes].map do |key, type|
  215. 2 " --attr #{key}:#{type}"
  216. end.join("")
  217. 1 attrs
  218. end
  219. end
  220. end
  221. end
  222. end

lib/compose/generators/view_generator.rb

77.1% lines covered

131 relevant lines. 101 lines covered and 30 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Generators
  9. 1 class ViewGenerator
  10. 1 def initialize(name, options = {})
  11. 19 @name = name
  12. 19 @options = options
  13. 19 @config = Core::ConfigManager.load_config
  14. end
  15. 1 def generate
  16. # Parse name for subdirectories
  17. 17 parts = @name.split('/')
  18. 17 view_name = parts.last
  19. 17 subdirectory = parts[0...-1].join('/') if parts.length > 1
  20. # Convert to proper case
  21. 17 view_class_name = to_pascal_case(view_name)
  22. 17 json_file_name = to_snake_case(view_name)
  23. # Get directories from config
  24. 17 source_dir = @config['source_directory'] || 'src/main'
  25. 17 layouts_dir = @config['layouts_directory'] || 'assets/Layouts'
  26. 17 view_dir = @config['view_directory'] || 'kotlin/com/example/kotlinjsonui/sample/views'
  27. 17 viewmodel_dir = @config['viewmodel_directory'] || 'kotlin/com/example/kotlinjsonui/sample/viewmodels'
  28. 17 data_dir = @config['data_directory'] || 'kotlin/com/example/kotlinjsonui/sample/data'
  29. 17 package_name = @config['package_name'] || 'com.example.kotlinjsonui.sample'
  30. # Create full paths with subdirectory support
  31. # Each view gets its own directory (using snake_case for Android)
  32. 17 view_folder_name = to_snake_case(view_name)
  33. 17 if subdirectory
  34. 1 json_path = File.join(source_dir, layouts_dir, subdirectory)
  35. 1 swift_path = File.join(source_dir, view_dir, subdirectory, view_folder_name)
  36. 1 viewmodel_path = File.join(source_dir, viewmodel_dir, subdirectory)
  37. 1 data_path = File.join(source_dir, data_dir, subdirectory)
  38. else
  39. 16 json_path = File.join(source_dir, layouts_dir)
  40. # Create a folder for each view (e.g., views/home_view/ for HomeView)
  41. 16 swift_path = File.join(source_dir, view_dir, view_folder_name)
  42. 16 viewmodel_path = File.join(source_dir, viewmodel_dir)
  43. 16 data_path = File.join(source_dir, data_dir)
  44. end
  45. # Create directories if they don't exist
  46. 17 FileUtils.mkdir_p(json_path)
  47. 17 FileUtils.mkdir_p(swift_path)
  48. 17 FileUtils.mkdir_p(viewmodel_path)
  49. 17 FileUtils.mkdir_p(data_path)
  50. # Create JSON file
  51. 17 json_file = File.join(json_path, "#{json_file_name}.json")
  52. 17 create_json_template(json_file, view_class_name)
  53. # Create Main View file (add View suffix to class name)
  54. 17 main_kotlin_file = File.join(swift_path, "#{view_class_name}View.kt")
  55. 17 create_main_view_template(main_kotlin_file, view_class_name, json_file_name, subdirectory, package_name)
  56. # Create Generated View file
  57. 17 generated_kotlin_file = File.join(swift_path, "#{view_class_name}GeneratedView.kt")
  58. 17 create_generated_view_template(generated_kotlin_file, view_class_name, json_file_name, subdirectory, package_name)
  59. # Create Data file
  60. 17 data_file = File.join(data_path, "#{view_class_name}Data.kt")
  61. 17 create_data_template(data_file, view_class_name, package_name)
  62. # Create ViewModel file
  63. 17 viewmodel_file = File.join(viewmodel_path, "#{view_class_name}ViewModel.kt")
  64. 17 create_viewmodel_template(viewmodel_file, view_class_name, json_file_name, subdirectory, package_name)
  65. # Update MainActivity if --root option is specified
  66. 17 if @options[:root]
  67. update_main_activity(view_class_name, package_name)
  68. end
  69. 17 puts "Generated Compose view:"
  70. 17 puts " JSON: #{json_file}"
  71. 17 puts " Main View: #{main_kotlin_file}"
  72. 17 puts " Generated View: #{generated_kotlin_file}"
  73. 17 puts " Data: #{data_file}"
  74. 17 puts " ViewModel: #{viewmodel_file}"
  75. 17 if @options[:root]
  76. puts " Updated MainActivity to use #{view_class_name}View as root"
  77. end
  78. 17 puts ""
  79. 17 puts "Next steps:"
  80. 17 puts " 1. Edit the JSON layout in #{json_file}"
  81. 17 puts " 2. Run 'kjui build' to generate the Compose code"
  82. end
  83. 1 private
  84. 1 def to_pascal_case(str)
  85. # Handle camelCase and PascalCase input
  86. # First convert to snake_case, then to PascalCase
  87. 17 snake = str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
  88. .gsub(/([a-z\d])([A-Z])/, '\1_\2')
  89. .downcase
  90. 17 snake.split(/[_\-]/).map(&:capitalize).join
  91. end
  92. 1 def to_snake_case(str)
  93. 68 str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
  94. .gsub(/([a-z\d])([A-Z])/, '\1_\2')
  95. .downcase
  96. end
  97. 1 def create_json_template(file_path, view_name)
  98. 17 return if File.exist?(file_path)
  99. template = {
  100. 16 type: "SafeAreaView",
  101. background: "#FFFFFF",
  102. child: [
  103. {
  104. type: "View",
  105. orientation: "vertical",
  106. padding: 16,
  107. child: [
  108. {
  109. type: "Label",
  110. text: "@{title}",
  111. fontSize: 24,
  112. fontWeight: "bold",
  113. fontColor: "#000000",
  114. marginBottom: 20
  115. },
  116. {
  117. type: "Label",
  118. text: "Welcome to #{view_name}",
  119. fontSize: 16,
  120. fontColor: "#666666",
  121. marginBottom: 30
  122. },
  123. {
  124. type: "Button",
  125. text: "Get Started",
  126. onclick: "onGetStarted",
  127. background: "#6200EE",
  128. fontColor: "#FFFFFF",
  129. padding: [12, 24],
  130. cornerRadius: 8
  131. }
  132. ]
  133. }
  134. ],
  135. data: [
  136. {
  137. name: "title",
  138. class: "String",
  139. defaultValue: "'#{view_name}'"
  140. }
  141. ]
  142. }
  143. 16 File.write(file_path, JSON.pretty_generate(template))
  144. 16 puts "Created JSON template: #{file_path}"
  145. end
  146. 1 def create_main_view_template(file_path, view_name, json_name, subdirectory, package_name)
  147. 17 return if File.exist?(file_path)
  148. 17 package_parts = package_name.split('.')
  149. # Each view has its own package (e.g., com.example.views.home_view)
  150. 17 view_folder_name = to_snake_case(view_name)
  151. 17 view_package = subdirectory ? "#{package_name}.views.#{subdirectory.gsub('/', '.')}.#{view_folder_name}" : "#{package_name}.views.#{view_folder_name}"
  152. 17 template = <<~KOTLIN
  153. package #{view_package}
  154. import androidx.compose.runtime.Composable
  155. import androidx.compose.runtime.collectAsState
  156. import androidx.compose.runtime.getValue
  157. import androidx.lifecycle.viewmodel.compose.viewModel
  158. import #{package_name}.viewmodels.#{view_name}ViewModel
  159. @Composable
  160. fun #{view_name}View(
  161. viewModel: #{view_name}ViewModel = viewModel()
  162. ) {
  163. val data by viewModel.data.collectAsState()
  164. #{view_name}GeneratedView(
  165. data = data,
  166. viewModel = viewModel
  167. )
  168. }
  169. KOTLIN
  170. 17 File.write(file_path, template)
  171. 17 puts "Created Main View template: #{file_path}"
  172. end
  173. 1 def create_generated_view_template(file_path, view_name, json_name, subdirectory, package_name)
  174. 17 return if File.exist?(file_path)
  175. 17 json_reference = subdirectory ? "#{subdirectory}/#{json_name}" : json_name
  176. # Each view has its own package (using snake_case for folder)
  177. 17 view_folder_name = to_snake_case(view_name)
  178. 17 view_package = subdirectory ? "#{package_name}.views.#{subdirectory.gsub('/', '.')}.#{view_folder_name}" : "#{package_name}.views.#{view_folder_name}"
  179. 17 template = <<~KOTLIN
  180. package #{view_package}
  181. import androidx.compose.foundation.background
  182. import androidx.compose.foundation.layout.*
  183. import androidx.compose.foundation.lazy.LazyColumn
  184. import androidx.compose.foundation.lazy.LazyRow
  185. import androidx.compose.material3.*
  186. import androidx.compose.runtime.Composable
  187. import androidx.compose.ui.Alignment
  188. import androidx.compose.ui.Modifier
  189. import androidx.compose.ui.graphics.Color
  190. import androidx.compose.ui.text.font.FontWeight
  191. import androidx.compose.ui.text.style.TextAlign
  192. import androidx.compose.ui.unit.dp
  193. import androidx.compose.ui.unit.sp
  194. import #{package_name}.data.#{view_name}Data
  195. import #{package_name}.viewmodels.#{view_name}ViewModel
  196. @Composable
  197. fun #{view_name}GeneratedView(
  198. data: #{view_name}Data,
  199. viewModel: #{view_name}ViewModel
  200. ) {
  201. // Generated Compose code from #{json_reference}.json
  202. // This will be updated when you run 'kjui build'
  203. // >>> GENERATED_CODE_START
  204. Column(
  205. modifier = Modifier
  206. .fillMaxSize()
  207. .padding(16.dp),
  208. horizontalAlignment = Alignment.CenterHorizontally
  209. ) {
  210. Text(
  211. text = data.title,
  212. fontSize = 24.sp,
  213. fontWeight = FontWeight.Bold
  214. )
  215. Spacer(modifier = Modifier.height(16.dp))
  216. Text(
  217. text = "Run 'kjui build' to generate Compose code",
  218. fontSize = 14.sp,
  219. color = Color.Gray
  220. )
  221. }
  222. // >>> GENERATED_CODE_END
  223. }
  224. KOTLIN
  225. 17 File.write(file_path, template)
  226. 17 puts "Created Generated View template: #{file_path}"
  227. end
  228. 1 def create_data_template(file_path, view_name, package_name)
  229. 17 return if File.exist?(file_path)
  230. 17 data_package = "#{package_name}.data"
  231. 17 template = <<~KOTLIN
  232. package #{data_package}
  233. data class #{view_name}Data(
  234. var title: String = "#{view_name}"
  235. // Add more data properties as needed based on your JSON structure
  236. ) {
  237. // Update properties from map
  238. fun update(map: Map<String, Any>) {
  239. map["title"]?.let {
  240. if (it is String) title = it
  241. }
  242. }
  243. // Convert to map for dynamic mode
  244. fun toMap(viewModel: Any? = null): Map<String, Any> {
  245. return mutableMapOf(
  246. "title" to title
  247. // Add action handlers if viewModel is provided
  248. )
  249. }
  250. }
  251. KOTLIN
  252. 17 File.write(file_path, template)
  253. 17 puts "Created Data template: #{file_path}"
  254. end
  255. 1 def create_viewmodel_template(file_path, view_name, json_name, subdirectory, package_name)
  256. 17 return if File.exist?(file_path)
  257. 17 json_reference = subdirectory ? "#{subdirectory}/#{json_name}" : json_name
  258. 17 viewmodel_package = "#{package_name}.viewmodels"
  259. 17 template = <<~KOTLIN
  260. package #{viewmodel_package}
  261. import android.app.Application
  262. import androidx.lifecycle.AndroidViewModel
  263. import kotlinx.coroutines.flow.MutableStateFlow
  264. import kotlinx.coroutines.flow.StateFlow
  265. import kotlinx.coroutines.flow.asStateFlow
  266. import #{package_name}.data.#{view_name}Data
  267. class #{view_name}ViewModel(application: Application) : AndroidViewModel(application) {
  268. // JSON file reference for hot reload
  269. val jsonFileName = "#{json_reference}"
  270. // Data model
  271. private val _data = MutableStateFlow(#{view_name}Data())
  272. val data: StateFlow<#{view_name}Data> = _data.asStateFlow()
  273. // Action handlers
  274. fun onGetStarted() {
  275. // Handle button tap
  276. }
  277. // Add more action handlers as needed
  278. fun updateData(updates: Map<String, Any>) {
  279. _data.value.update(updates)
  280. _data.value = _data.value.copy() // Trigger recomposition
  281. }
  282. }
  283. KOTLIN
  284. 17 File.write(file_path, template)
  285. 17 puts "Created ViewModel template: #{file_path}"
  286. end
  287. 1 def update_main_activity(view_name, package_name)
  288. source_dir = @config['source_directory'] || 'src/main'
  289. # Find MainActivity file
  290. activity_files = Dir.glob(File.join(source_dir, '**/MainActivity.kt'))
  291. if activity_files.empty?
  292. puts "Warning: Could not find MainActivity.kt file to update"
  293. return
  294. end
  295. activity_file = activity_files.first
  296. content = File.read(activity_file)
  297. # Add import for the new view (view is in its own package with snake_case folder)
  298. view_folder_name = to_snake_case(view_name)
  299. import_line = "import #{package_name}.views.#{view_folder_name}.#{view_name}View"
  300. unless content.include?(import_line)
  301. # Find the last import line and add after it
  302. if content =~ /^((?:.*\nimport .*\n)+)/m
  303. imports_block = $1
  304. # Add the new import after the last import
  305. new_imports = imports_block.chomp + "\n#{import_line}\n"
  306. content.sub!(imports_block, new_imports)
  307. end
  308. end
  309. # Update setContent - look for the pattern and replace the content inside
  310. updated = false
  311. # Pattern 1: Full setContent block with theme
  312. if content =~ /setContent\s*\{[\s\S]*?\n\s{8}\}/m
  313. content.gsub!(/setContent\s*\{[\s\S]*?\n\s{8}\}/m) do
  314. <<~KOTLIN.chomp
  315. setContent {
  316. KotlinJsonUITheme {
  317. Surface(
  318. modifier = Modifier.fillMaxSize(),
  319. color = MaterialTheme.colorScheme.background
  320. ) {
  321. #{view_name}View()
  322. }
  323. }
  324. }
  325. KOTLIN
  326. end
  327. updated = true
  328. # Pattern 2: Simple setContent
  329. elsif content =~ /setContent\s*\{[^}]*\}/m
  330. content.gsub!(/setContent\s*\{[^}]*\}/m) do
  331. <<~KOTLIN.chomp
  332. setContent {
  333. #{view_name}View()
  334. }
  335. KOTLIN
  336. end
  337. updated = true
  338. end
  339. if updated
  340. File.write(activity_file, content)
  341. puts "Updated MainActivity to use #{view_name}View as root"
  342. else
  343. puts "Warning: Could not update MainActivity automatically"
  344. puts "Please manually update your MainActivity to use #{view_name}View()"
  345. end
  346. end
  347. end
  348. end
  349. end
  350. end

lib/compose/helpers/import_manager.rb

100.0% lines covered

23 relevant lines. 23 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module KjuiTools
  3. 1 module Compose
  4. 1 module Helpers
  5. 1 class ImportManager
  6. 1 def self.get_imports_map(package_name = nil)
  7. # Use provided package name or default to sample app
  8. 15 pkg_name = package_name || 'com.example.kotlinjsonui.sample'
  9. {
  10. 15 lazy_column: "import androidx.compose.foundation.lazy.LazyColumn",
  11. lazy_row: "import androidx.compose.foundation.lazy.LazyRow",
  12. background: "import androidx.compose.foundation.background",
  13. border: "import androidx.compose.foundation.border",
  14. shape: ["import androidx.compose.foundation.shape.RoundedCornerShape",
  15. "import androidx.compose.ui.draw.clip"],
  16. text_align: "import androidx.compose.ui.text.style.TextAlign",
  17. text_overflow: "import androidx.compose.ui.text.style.TextOverflow",
  18. text_style: "import androidx.compose.ui.text.TextStyle",
  19. visual_transformation: "import androidx.compose.ui.text.input.PasswordVisualTransformation",
  20. shadow: "import androidx.compose.ui.draw.shadow",
  21. arrangement: "import androidx.compose.foundation.layout.Arrangement",
  22. keyboard_type: ["import androidx.compose.foundation.text.KeyboardOptions",
  23. "import androidx.compose.ui.text.input.KeyboardType"],
  24. ime_action: "import androidx.compose.ui.text.input.ImeAction",
  25. button_colors: "import androidx.compose.material3.ButtonDefaults",
  26. button_padding: "import androidx.compose.foundation.layout.PaddingValues",
  27. text_decoration: "import androidx.compose.ui.text.style.TextDecoration",
  28. shadow_style: ["import androidx.compose.ui.text.TextStyle",
  29. "import androidx.compose.ui.graphics.Shadow",
  30. "import androidx.compose.ui.geometry.Offset"],
  31. switch_colors: "import androidx.compose.material3.SwitchDefaults",
  32. slider_colors: "import androidx.compose.material3.SliderDefaults",
  33. checkbox_colors: "import androidx.compose.material3.CheckboxDefaults",
  34. dropdown_menu: ["import androidx.compose.material3.DropdownMenu",
  35. "import androidx.compose.material3.DropdownMenuItem",
  36. "import androidx.compose.material.icons.Icons",
  37. "import androidx.compose.material.icons.filled.ArrowDropDown",
  38. "import androidx.compose.foundation.clickable"],
  39. outlined_text_field: "import androidx.compose.material3.OutlinedTextField",
  40. icons: ["import androidx.compose.material.icons.Icons",
  41. "import androidx.compose.material.icons.filled.*",
  42. "import androidx.compose.material.icons.outlined.*"],
  43. icon_button: "import androidx.compose.material3.IconButton",
  44. clickable: "import androidx.compose.foundation.clickable",
  45. radio_colors: "import androidx.compose.material3.RadioButtonDefaults",
  46. tab_row: ["import androidx.compose.material3.TabRow",
  47. "import androidx.compose.material3.Tab"],
  48. async_image: "import coil.compose.AsyncImage",
  49. content_scale: "import androidx.compose.ui.layout.ContentScale",
  50. lazy_grid: ["import androidx.compose.foundation.lazy.grid.LazyVerticalGrid",
  51. "import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid",
  52. "import androidx.compose.foundation.lazy.grid.GridCells"],
  53. grid_item_span: "import androidx.compose.foundation.lazy.grid.GridItemSpan",
  54. webview: ["import android.webkit.WebView",
  55. "import android.webkit.WebViewClient",
  56. "import android.webkit.WebChromeClient",
  57. "import androidx.compose.ui.viewinterop.AndroidView"],
  58. constraint_layout: ["import androidx.constraintlayout.compose.ConstraintLayout",
  59. "import androidx.constraintlayout.compose.Dimension"],
  60. remember_state: ["import androidx.compose.runtime.remember",
  61. "import androidx.compose.runtime.mutableStateOf",
  62. "import androidx.compose.runtime.getValue",
  63. "import androidx.compose.runtime.setValue"],
  64. remember: "import androidx.compose.runtime.remember",
  65. LaunchedEffect: "import androidx.compose.runtime.LaunchedEffect",
  66. bias_alignment: "import androidx.compose.ui.BiasAlignment",
  67. circle_shape: "import androidx.compose.foundation.shape.CircleShape",
  68. alpha: "import androidx.compose.ui.draw.alpha",
  69. image: "import androidx.compose.foundation.Image",
  70. painter_resource: "import androidx.compose.ui.res.painterResource",
  71. string_resource: "import androidx.compose.ui.res.stringResource",
  72. color_resource: "import androidx.compose.ui.res.colorResource",
  73. r_class: "import #{pkg_name}.R",
  74. gradient: "import androidx.compose.ui.graphics.Brush",
  75. blur: "import androidx.compose.ui.draw.blur",
  76. navigation: ["import androidx.navigation.NavController",
  77. "import androidx.navigation.compose.NavHost",
  78. "import androidx.navigation.compose.composable",
  79. "import androidx.navigation.compose.rememberNavController"],
  80. selectbox_component: "import com.kotlinjsonui.components.SelectBox",
  81. date_selectbox_component: "import com.kotlinjsonui.components.DateSelectBox",
  82. simple_date_selectbox_component: "import com.kotlinjsonui.components.SimpleDateSelectBox",
  83. visibility_wrapper: "import com.kotlinjsonui.components.VisibilityWrapper",
  84. custom_textfield: ["import com.kotlinjsonui.components.CustomTextField",
  85. "import com.kotlinjsonui.components.CustomTextFieldWithMargins"],
  86. annotated_string: ["import androidx.compose.ui.text.AnnotatedString",
  87. "import androidx.compose.ui.text.buildAnnotatedString",
  88. "import androidx.compose.ui.text.SpanStyle",
  89. "import androidx.compose.ui.text.withStyle"],
  90. clickable_text: "import androidx.compose.foundation.text.ClickableText",
  91. partial_attributes_text: ["import com.kotlinjsonui.components.PartialAttributesText",
  92. "import com.kotlinjsonui.components.PartialAttribute"],
  93. segment: "import com.kotlinjsonui.components.Segment",
  94. dynamic_mode_manager: "import com.kotlinjsonui.core.DynamicModeManager",
  95. safe_dynamic_view: "import com.kotlinjsonui.components.SafeDynamicView",
  96. circular_progress_indicator: "import androidx.compose.material3.CircularProgressIndicator",
  97. wrapContentSize: "import androidx.compose.foundation.layout.wrapContentSize",
  98. box: "import androidx.compose.foundation.layout.Box",
  99. DynamicView: "import com.kotlinjsonui.dynamic.DynamicView",
  100. JsonObject: "import com.google.gson.JsonObject",
  101. JsonParser: "import com.google.gson.JsonParser"
  102. }
  103. end
  104. 1 def self.update_imports(content, required_imports)
  105. 6 imports_map = get_imports_map
  106. 6 required_imports.each do |import_key|
  107. 6 import_lines = imports_map[import_key]
  108. 6 next unless import_lines
  109. 5 if import_lines.is_a?(Array)
  110. 1 import_lines.each do |import_line|
  111. 2 unless content.include?(import_line)
  112. # Add import after the last import statement
  113. 2 if content =~ /^(import .+\n)+/m
  114. 2 last_import_end = $~.end(0)
  115. 2 content.insert(last_import_end, "#{import_line}\n")
  116. end
  117. end
  118. end
  119. else
  120. 4 unless content.include?(import_lines)
  121. # Add import after the last import statement
  122. 3 if content =~ /^(import .+\n)+/m
  123. 3 last_import_end = $~.end(0)
  124. 3 content.insert(last_import_end, "#{import_lines}\n")
  125. end
  126. end
  127. end
  128. end
  129. 6 content
  130. end
  131. end
  132. end
  133. end
  134. end

lib/compose/helpers/modifier_builder.rb

72.28% lines covered

303 relevant lines. 219 lines covered and 84 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative 'resource_resolver'
  3. 1 module KjuiTools
  4. 1 module Compose
  5. 1 module Helpers
  6. # Helper class to build Compose modifiers from JSON attributes
  7. 1 class ModifierBuilder
  8. 1 def self.build_padding(json_data)
  9. 443 modifiers = []
  10. # Handle padding attribute (can be array [top, right, bottom, left] or single value)
  11. 443 if json_data['padding']
  12. 9 if json_data['padding'].is_a?(Array)
  13. 1 pad_values = json_data['padding']
  14. 1 if pad_values.length == 4
  15. 1 modifiers << ".padding(top = #{pad_values[0]}.dp, end = #{pad_values[1]}.dp, bottom = #{pad_values[2]}.dp, start = #{pad_values[3]}.dp)"
  16. elsif pad_values.length == 1
  17. modifiers << ".padding(#{pad_values[0]}.dp)"
  18. end
  19. else
  20. 8 modifiers << ".padding(#{json_data['padding']}.dp)"
  21. end
  22. end
  23. # Handle paddings attribute (same as padding)
  24. 443 if json_data['paddings']
  25. 1 if json_data['paddings'].is_a?(Array)
  26. pad_values = json_data['paddings']
  27. if pad_values.length == 4
  28. modifiers << ".padding(top = #{pad_values[0]}.dp, end = #{pad_values[1]}.dp, bottom = #{pad_values[2]}.dp, start = #{pad_values[3]}.dp)"
  29. elsif pad_values.length == 1
  30. modifiers << ".padding(#{pad_values[0]}.dp)"
  31. end
  32. else
  33. 1 modifiers << ".padding(#{json_data['paddings']}.dp)"
  34. end
  35. end
  36. # Individual padding attributes
  37. 443 modifiers << ".padding(top = #{json_data['paddingTop']}.dp)" if json_data['paddingTop']
  38. 443 modifiers << ".padding(bottom = #{json_data['paddingBottom']}.dp)" if json_data['paddingBottom']
  39. 443 modifiers << ".padding(start = #{json_data['paddingLeft']}.dp)" if json_data['paddingLeft']
  40. 443 modifiers << ".padding(end = #{json_data['paddingRight']}.dp)" if json_data['paddingRight']
  41. 443 modifiers
  42. end
  43. 1 def self.build_margins(json_data)
  44. 476 modifiers = []
  45. # Handle margins attribute (can be array [top, right, bottom, left] or single value)
  46. 476 if json_data['margins']
  47. 16 if json_data['margins'].is_a?(Array)
  48. 15 margin_values = json_data['margins']
  49. 15 if margin_values.length == 4
  50. 5 modifiers << ".padding(top = #{margin_values[0]}.dp, end = #{margin_values[1]}.dp, bottom = #{margin_values[2]}.dp, start = #{margin_values[3]}.dp)"
  51. 10 elsif margin_values.length == 1
  52. 5 modifiers << ".padding(#{margin_values[0]}.dp)"
  53. end
  54. else
  55. 1 modifiers << ".padding(#{json_data['margins']}.dp)"
  56. end
  57. end
  58. # Individual margin attributes
  59. 476 modifiers << ".padding(top = #{json_data['topMargin']}.dp)" if json_data['topMargin']
  60. 476 modifiers << ".padding(bottom = #{json_data['bottomMargin']}.dp)" if json_data['bottomMargin']
  61. 476 modifiers << ".padding(start = #{json_data['leftMargin']}.dp)" if json_data['leftMargin']
  62. 476 modifiers << ".padding(end = #{json_data['rightMargin']}.dp)" if json_data['rightMargin']
  63. 476 modifiers
  64. end
  65. 1 def self.build_weight(json_data, parent_orientation = nil)
  66. 7 modifiers = []
  67. # Weight only works in Row/Column contexts
  68. # Weight must be greater than 0 in Compose
  69. 7 if json_data['weight'] && parent_orientation && json_data['weight'].to_f > 0
  70. 5 modifiers << ".weight(#{json_data['weight']}f)"
  71. end
  72. 7 modifiers
  73. end
  74. 1 def self.build_size(json_data)
  75. 353 modifiers = []
  76. # Width - skip if weight is present and width is 0
  77. 353 if json_data['width'] == 'matchParent'
  78. 3 modifiers << ".fillMaxWidth()"
  79. 350 elsif json_data['width'] == 'wrapContent'
  80. 1 modifiers << ".wrapContentWidth()"
  81. 349 elsif json_data['width'] && !(json_data['weight'] && json_data['width'] == 0)
  82. 11 modifiers << ".width(#{json_data['width']}.dp)"
  83. end
  84. # Height - skip if heightWeight is present and height is 0
  85. 353 if json_data['height'] == 'matchParent'
  86. 1 modifiers << ".fillMaxHeight()"
  87. 352 elsif json_data['height'] == 'wrapContent'
  88. modifiers << ".wrapContentHeight()"
  89. 352 elsif json_data['height'] && !(json_data['heightWeight'] && json_data['height'] == 0)
  90. 9 modifiers << ".height(#{json_data['height']}.dp)"
  91. end
  92. # Min/Max constraints
  93. 353 if json_data['minWidth']
  94. 1 modifiers << ".widthIn(min = #{json_data['minWidth']}.dp)"
  95. end
  96. 353 if json_data['maxWidth']
  97. 1 modifiers << ".widthIn(max = #{json_data['maxWidth']}.dp)"
  98. end
  99. 353 if json_data['minHeight']
  100. modifiers << ".heightIn(min = #{json_data['minHeight']}.dp)"
  101. end
  102. 353 if json_data['maxHeight']
  103. modifiers << ".heightIn(max = #{json_data['maxHeight']}.dp)"
  104. end
  105. # Combined min/max if both specified
  106. 353 if json_data['minWidth'] && json_data['maxWidth']
  107. 3 modifiers = modifiers.reject { |m| m.include?('.widthIn') }
  108. 1 modifiers << ".widthIn(min = #{json_data['minWidth']}.dp, max = #{json_data['maxWidth']}.dp)"
  109. end
  110. 353 if json_data['minHeight'] && json_data['maxHeight']
  111. modifiers = modifiers.reject { |m| m.include?('.heightIn') }
  112. modifiers << ".heightIn(min = #{json_data['minHeight']}.dp, max = #{json_data['maxHeight']}.dp)"
  113. end
  114. # Aspect ratio
  115. 353 if json_data['aspectWidth'] && json_data['aspectHeight']
  116. 1 ratio = json_data['aspectWidth'].to_f / json_data['aspectHeight'].to_f
  117. 1 modifiers << ".aspectRatio(#{ratio}f)"
  118. end
  119. 353 modifiers
  120. end
  121. 1 def self.build_shadow(json_data, required_imports = nil)
  122. 45 modifiers = []
  123. 45 if json_data['shadow']
  124. 3 required_imports&.add(:shadow)
  125. 3 if json_data['shadow'].is_a?(String)
  126. # Simple shadow with color
  127. 2 modifiers << ".shadow(4.dp, shape = RectangleShape)"
  128. 1 elsif json_data['shadow'].is_a?(Hash)
  129. # Complex shadow configuration
  130. 1 shadow = json_data['shadow']
  131. 1 elevation = shadow['radius'] || 4
  132. 1 shape = json_data['cornerRadius'] ? "RoundedCornerShape(#{json_data['cornerRadius']}.dp)" : "RectangleShape"
  133. 1 modifiers << ".shadow(#{elevation}.dp, shape = #{shape})"
  134. end
  135. end
  136. 45 modifiers
  137. end
  138. 1 def self.build_background(json_data, required_imports = nil)
  139. 156 modifiers = []
  140. 156 if json_data['background']
  141. 3 required_imports&.add(:background)
  142. # Use ResourceResolver to process background color
  143. 3 background_color = ResourceResolver.process_color(json_data['background'], required_imports)
  144. 3 if json_data['cornerRadius'] || json_data['borderColor'] || json_data['borderWidth']
  145. 1 required_imports&.add(:border)
  146. 1 required_imports&.add(:shape)
  147. 1 if json_data['cornerRadius']
  148. 1 modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
  149. end
  150. 1 if json_data['borderColor'] && json_data['borderWidth']
  151. # Use ResourceResolver to process border color
  152. border_color = ResourceResolver.process_color(json_data['borderColor'], required_imports)
  153. border_shape = json_data['cornerRadius'] ? "RoundedCornerShape(#{json_data['cornerRadius']}.dp)" : "RectangleShape"
  154. modifiers << ".border(#{json_data['borderWidth']}.dp, #{border_color}, #{border_shape})"
  155. end
  156. 1 modifiers << ".background(#{background_color})"
  157. else
  158. 2 modifiers << ".background(#{background_color})"
  159. end
  160. 153 elsif json_data['cornerRadius'] || json_data['borderColor'] || json_data['borderWidth']
  161. 1 required_imports&.add(:border)
  162. 1 required_imports&.add(:shape)
  163. 1 if json_data['cornerRadius']
  164. 1 modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
  165. end
  166. 1 if json_data['borderColor'] && json_data['borderWidth']
  167. # Use ResourceResolver to process border color
  168. 1 border_color = ResourceResolver.process_color(json_data['borderColor'], required_imports)
  169. 1 border_shape = json_data['cornerRadius'] ? "RoundedCornerShape(#{json_data['cornerRadius']}.dp)" : "RectangleShape"
  170. 1 modifiers << ".border(#{json_data['borderWidth']}.dp, #{border_color}, #{border_shape})"
  171. end
  172. end
  173. 156 modifiers
  174. end
  175. 1 def self.build_visibility(json_data, required_imports = nil)
  176. 113 modifiers = []
  177. 113 visibility_info = {}
  178. # Handle visibility attribute (static or data-bound)
  179. 113 if json_data['visibility']
  180. 5 if json_data['visibility'].is_a?(String) && json_data['visibility'].start_with?('@{')
  181. # Data binding for visibility
  182. 2 variable = json_data['visibility'].gsub('@{', '').gsub('}', '')
  183. 2 visibility_info[:visibility_binding] = "data.#{variable}"
  184. 2 required_imports&.add(:visibility_wrapper)
  185. else
  186. # Static visibility
  187. 3 visibility_info[:visibility] = json_data['visibility']
  188. 3 required_imports&.add(:visibility_wrapper)
  189. end
  190. end
  191. # Handle hidden attribute (boolean or data binding)
  192. 113 if json_data['hidden']
  193. 4 if json_data['hidden'].is_a?(String) && json_data['hidden'].start_with?('@{')
  194. # Data binding for hidden
  195. 2 variable = json_data['hidden'].gsub('@{', '').gsub('}', '')
  196. 2 visibility_info[:hidden_binding] = "data.#{variable}"
  197. 2 required_imports&.add(:visibility_wrapper)
  198. 2 elsif json_data['hidden'] == true
  199. 2 visibility_info[:hidden] = true
  200. 2 required_imports&.add(:visibility_wrapper)
  201. end
  202. end
  203. # Handle alpha/opacity attribute separately (not part of visibility wrapper)
  204. # Support both 'alpha' and 'opacity' for compatibility
  205. 113 alpha_value = json_data['alpha'] || json_data['opacity']
  206. 113 if alpha_value
  207. 2 required_imports&.add(:alpha)
  208. 2 modifiers << ".alpha(#{alpha_value}f)"
  209. end
  210. # Return both visibility info and modifiers
  211. 113 { modifiers: modifiers, visibility_info: visibility_info }
  212. end
  213. 1 def self.build_alignment(json_data, required_imports = nil, parent_type = nil)
  214. 139 modifiers = []
  215. # For Row, only vertical alignment is allowed
  216. 139 if parent_type == 'Row'
  217. 4 if json_data['alignTop']
  218. 1 modifiers << ".align(Alignment.Top)"
  219. 3 elsif json_data['alignBottom']
  220. modifiers << ".align(Alignment.Bottom)"
  221. 3 elsif json_data['centerVertical']
  222. 1 modifiers << ".align(Alignment.CenterVertically)"
  223. end
  224. # For Column, only horizontal alignment is allowed
  225. 135 elsif parent_type == 'Column'
  226. 4 if json_data['alignLeft']
  227. 1 modifiers << ".align(Alignment.Start)"
  228. 3 elsif json_data['alignRight']
  229. modifiers << ".align(Alignment.End)"
  230. 3 elsif json_data['centerHorizontal']
  231. 1 modifiers << ".align(Alignment.CenterHorizontally)"
  232. end
  233. # For Box and other containers, full alignment options
  234. 131 elsif parent_type == 'Box'
  235. # Check if any alignment is specified
  236. 4 has_alignment = json_data['alignTop'] || json_data['alignBottom'] ||
  237. json_data['alignLeft'] || json_data['alignRight'] ||
  238. json_data['centerHorizontal'] || json_data['centerVertical'] ||
  239. json_data['centerInParent']
  240. # First check for both-direction constraints (centering behavior)
  241. 4 has_horizontal_both = json_data['alignLeft'] && json_data['alignRight']
  242. 4 has_vertical_both = json_data['alignTop'] && json_data['alignBottom']
  243. # Handle combined alignments
  244. 4 if has_horizontal_both && has_vertical_both
  245. # Both horizontal and vertical constraints - center completely
  246. modifiers << ".align(Alignment.Center)"
  247. 4 elsif has_horizontal_both && json_data['alignTop']
  248. # Center horizontally, align top
  249. required_imports&.add(:bias_alignment)
  250. modifiers << ".align(BiasAlignment(0f, -1f))"
  251. 4 elsif has_horizontal_both && json_data['alignBottom']
  252. # Center horizontally, align bottom
  253. required_imports&.add(:bias_alignment)
  254. modifiers << ".align(BiasAlignment(0f, 1f))"
  255. 4 elsif has_horizontal_both
  256. # Just center horizontally
  257. required_imports&.add(:bias_alignment)
  258. modifiers << ".align(BiasAlignment(0f, 0f))"
  259. 4 elsif has_vertical_both && json_data['alignLeft']
  260. # Center vertically, align left
  261. modifiers << ".align(Alignment.CenterStart)"
  262. 4 elsif has_vertical_both && json_data['alignRight']
  263. # Center vertically, align right
  264. modifiers << ".align(Alignment.CenterEnd)"
  265. 4 elsif has_vertical_both
  266. # Just center vertically
  267. required_imports&.add(:bias_alignment)
  268. modifiers << ".align(BiasAlignment(0f, 0f))"
  269. 4 elsif json_data['alignTop'] && json_data['alignLeft']
  270. 1 modifiers << ".align(Alignment.TopStart)"
  271. 3 elsif json_data['alignTop'] && json_data['alignRight']
  272. modifiers << ".align(Alignment.TopEnd)"
  273. 3 elsif json_data['alignBottom'] && json_data['alignLeft']
  274. modifiers << ".align(Alignment.BottomStart)"
  275. 3 elsif json_data['alignBottom'] && json_data['alignRight']
  276. 1 modifiers << ".align(Alignment.BottomEnd)"
  277. 2 elsif json_data['alignTop'] && json_data['centerHorizontal']
  278. # TopCenter doesn't exist in BoxScope, use BiasAlignment
  279. 1 required_imports&.add(:bias_alignment)
  280. 1 modifiers << ".align(BiasAlignment(0f, -1f))"
  281. 1 elsif json_data['alignBottom'] && json_data['centerHorizontal']
  282. # BottomCenter doesn't exist in BoxScope, use BiasAlignment
  283. required_imports&.add(:bias_alignment)
  284. modifiers << ".align(BiasAlignment(0f, 1f))"
  285. 1 elsif json_data['alignLeft'] && json_data['centerVertical']
  286. modifiers << ".align(Alignment.CenterStart)"
  287. 1 elsif json_data['alignRight'] && json_data['centerVertical']
  288. modifiers << ".align(Alignment.CenterEnd)"
  289. 1 elsif json_data['centerInParent']
  290. 1 modifiers << ".align(Alignment.Center)"
  291. # Handle single alignments for Box
  292. elsif json_data['alignTop']
  293. # Just top alignment - align to top-left
  294. required_imports&.add(:bias_alignment)
  295. modifiers << ".align(BiasAlignment(-1f, -1f))"
  296. elsif json_data['alignBottom']
  297. # Just bottom alignment - align to bottom-left
  298. required_imports&.add(:bias_alignment)
  299. modifiers << ".align(BiasAlignment(-1f, 1f))"
  300. elsif json_data['alignLeft']
  301. # Just left alignment - align to top-left
  302. required_imports&.add(:bias_alignment)
  303. modifiers << ".align(BiasAlignment(-1f, -1f))"
  304. elsif json_data['alignRight']
  305. # Just right alignment - align to top-right
  306. required_imports&.add(:bias_alignment)
  307. modifiers << ".align(BiasAlignment(1f, -1f))"
  308. elsif json_data['centerHorizontal']
  309. # Center horizontally only - align to top-center
  310. required_imports&.add(:bias_alignment)
  311. modifiers << ".align(BiasAlignment(0f, -1f))"
  312. elsif json_data['centerVertical']
  313. # Center vertically only - align to center-left
  314. required_imports&.add(:bias_alignment)
  315. modifiers << ".align(BiasAlignment(-1f, 0f))"
  316. elsif !has_alignment
  317. # No alignment specified - default to TopStart (top-left)
  318. modifiers << ".align(Alignment.TopStart)"
  319. end
  320. end
  321. 139 modifiers
  322. end
  323. 1 def self.build_relative_positioning(json_data)
  324. # These attributes require ConstraintLayout
  325. # They generate constraint references instead of modifiers
  326. 8 constraints = []
  327. # Extract margins for use in constraints
  328. 8 top_margin = json_data['topMargin'] || 0
  329. 8 bottom_margin = json_data['bottomMargin'] || 0
  330. 8 start_margin = json_data['leftMargin'] || 0
  331. 8 end_margin = json_data['rightMargin'] || 0
  332. 8 if json_data['margins'] && json_data['margins'].is_a?(Array) && json_data['margins'].length == 4
  333. top_margin = json_data['margins'][0] unless json_data['topMargin']
  334. end_margin = json_data['margins'][1] unless json_data['rightMargin']
  335. bottom_margin = json_data['margins'][2] unless json_data['bottomMargin']
  336. start_margin = json_data['margins'][3] unless json_data['leftMargin']
  337. end
  338. # Relative to other views
  339. 8 if json_data['alignTopOfView']
  340. 2 margin = bottom_margin > 0 ? ", margin = #{bottom_margin}.dp" : ""
  341. 2 constraints << "bottom.linkTo(#{json_data['alignTopOfView']}.top#{margin})"
  342. end
  343. 8 if json_data['alignBottomOfView']
  344. margin = top_margin > 0 ? ", margin = #{top_margin}.dp" : ""
  345. constraints << "top.linkTo(#{json_data['alignBottomOfView']}.bottom#{margin})"
  346. end
  347. 8 if json_data['alignLeftOfView']
  348. margin = end_margin > 0 ? ", margin = #{end_margin}.dp" : ""
  349. constraints << "end.linkTo(#{json_data['alignLeftOfView']}.start#{margin})"
  350. end
  351. 8 if json_data['alignRightOfView']
  352. margin = start_margin > 0 ? ", margin = #{start_margin}.dp" : ""
  353. constraints << "start.linkTo(#{json_data['alignRightOfView']}.end#{margin})"
  354. end
  355. # Align edges with other views
  356. # For align operations, use negative margins to move in the expected direction
  357. 8 if json_data['alignTopView']
  358. # alignTop with topMargin means move DOWN from the aligned position
  359. # linkTo margin pushes away, so use negative to pull closer (move down)
  360. margin = top_margin > 0 ? ", margin = (-#{top_margin}).dp" : ""
  361. constraints << "top.linkTo(#{json_data['alignTopView']}.top#{margin})"
  362. end
  363. 8 if json_data['alignBottomView']
  364. # alignBottom with bottomMargin means move UP from the aligned position
  365. # linkTo margin pushes away, so use negative to pull closer (move up)
  366. margin = bottom_margin > 0 ? ", margin = (-#{bottom_margin}).dp" : ""
  367. constraints << "bottom.linkTo(#{json_data['alignBottomView']}.bottom#{margin})"
  368. end
  369. 8 if json_data['alignLeftView']
  370. # alignLeft with leftMargin means move RIGHT from the aligned position
  371. # linkTo margin pushes away, so use negative to pull closer (move right)
  372. margin = start_margin > 0 ? ", margin = (-#{start_margin}).dp" : ""
  373. constraints << "start.linkTo(#{json_data['alignLeftView']}.start#{margin})"
  374. end
  375. 8 if json_data['alignRightView']
  376. # alignRight with rightMargin means move LEFT from the aligned position
  377. # linkTo margin pushes away, so use negative to pull closer (move left)
  378. margin = end_margin > 0 ? ", margin = (-#{end_margin}).dp" : ""
  379. constraints << "end.linkTo(#{json_data['alignRightView']}.end#{margin})"
  380. end
  381. # Center with other views
  382. 8 if json_data['alignCenterVerticalView']
  383. constraints << "top.linkTo(#{json_data['alignCenterVerticalView']}.top)"
  384. constraints << "bottom.linkTo(#{json_data['alignCenterVerticalView']}.bottom)"
  385. end
  386. 8 if json_data['alignCenterHorizontalView']
  387. constraints << "start.linkTo(#{json_data['alignCenterHorizontalView']}.start)"
  388. constraints << "end.linkTo(#{json_data['alignCenterHorizontalView']}.end)"
  389. end
  390. # Parent constraints
  391. # For parent alignment, margins should work normally as offsets
  392. 8 if json_data['alignTop']
  393. 4 margin = top_margin > 0 ? ", margin = #{top_margin}.dp" : ""
  394. 4 constraints << "top.linkTo(parent.top#{margin})"
  395. end
  396. 8 if json_data['alignBottom']
  397. margin = bottom_margin > 0 ? ", margin = #{bottom_margin}.dp" : ""
  398. constraints << "bottom.linkTo(parent.bottom#{margin})"
  399. end
  400. 8 if json_data['alignLeft']
  401. 2 margin = start_margin > 0 ? ", margin = #{start_margin}.dp" : ""
  402. 2 constraints << "start.linkTo(parent.start#{margin})"
  403. end
  404. 8 if json_data['alignRight']
  405. margin = end_margin > 0 ? ", margin = #{end_margin}.dp" : ""
  406. constraints << "end.linkTo(parent.end#{margin})"
  407. end
  408. 8 if json_data['centerHorizontal']
  409. constraints << "start.linkTo(parent.start)"
  410. constraints << "end.linkTo(parent.end)"
  411. end
  412. 8 if json_data['centerVertical']
  413. constraints << "top.linkTo(parent.top)"
  414. constraints << "bottom.linkTo(parent.bottom)"
  415. end
  416. 8 if json_data['centerInParent']
  417. 2 constraints << "top.linkTo(parent.top)"
  418. 2 constraints << "bottom.linkTo(parent.bottom)"
  419. 2 constraints << "start.linkTo(parent.start)"
  420. 2 constraints << "end.linkTo(parent.end)"
  421. end
  422. 8 constraints
  423. end
  424. 1 def self.format(modifiers, depth)
  425. 176 return "" if modifiers.empty?
  426. # Check if first modifier is already "Modifier"
  427. 91 if modifiers[0] == "Modifier"
  428. 3 code = "\n" + indent("modifier = Modifier", depth + 1)
  429. # Skip the first "Modifier" and process the rest
  430. 3 modifiers[1..-1].each do |mod|
  431. 6 code += "\n" + indent(" #{mod}", depth + 1)
  432. end
  433. else
  434. 88 code = "\n" + indent("modifier = Modifier", depth + 1)
  435. 88 if modifiers.length == 1 && modifiers[0].start_with?('.')
  436. 61 code += modifiers[0]
  437. else
  438. 27 modifiers.each do |mod|
  439. 57 code += "\n" + indent(" #{mod}", depth + 1)
  440. end
  441. end
  442. end
  443. 91 code
  444. end
  445. 1 private
  446. 1 def self.indent(text, level)
  447. 154 return text if level == 0
  448. 154 spaces = ' ' * level
  449. 154 text.split("\n").map { |line|
  450. 154 line.empty? ? line : spaces + line
  451. }.join("\n")
  452. end
  453. end
  454. end
  455. end
  456. end

lib/compose/helpers/resource_resolver.rb

89.42% lines covered

104 relevant lines. 93 lines covered and 11 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'rexml/document'
  3. 1 require 'json'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Helpers
  9. 1 class ResourceResolver
  10. 1 class << self
  11. # Don't cache - just load each time to avoid issues
  12. 1 def cached_config
  13. 469 Core::ConfigManager.load_config
  14. end
  15. 1 def cached_source_path
  16. 236 Core::ProjectFinder.get_full_source_path || Dir.pwd
  17. end
  18. # Process text with data binding and resource resolution
  19. 1 def process_text(text, required_imports = nil)
  20. 113 return quote(text) unless text.is_a?(String)
  21. # Handle data binding expressions
  22. 113 if text.match(/@\{([^}]+)\}/)
  23. 4 variable = $1
  24. 4 if variable.include?(' ?? ')
  25. 1 parts = variable.split(' ?? ')
  26. 1 var_name = parts[0].strip
  27. 1 return "\"\${data.#{var_name}}\""
  28. else
  29. 3 return "\"\${data.#{variable}}\""
  30. end
  31. end
  32. # Skip resource resolution if we're in the extraction phase
  33. # (Resources directory doesn't exist yet)
  34. 109 source_directory = cached_config['source_directory'] || 'src/main'
  35. 109 layouts_dir = File.join(cached_source_path, source_directory, cached_config['layouts_directory'] || 'assets/Layouts')
  36. 109 resources_dir = File.join(layouts_dir, 'Resources')
  37. # If Resources directory doesn't exist, we're in extraction phase
  38. # Just return quoted text
  39. 109 return quote(text) unless File.exist?(resources_dir)
  40. # Try to resolve as a string resource
  41. 1 resolved = resolve_string(text, cached_config, cached_source_path)
  42. 1 if resolved.include?('stringResource')
  43. 1 required_imports&.add(:string_resource)
  44. 1 required_imports&.add(:r_class)
  45. end
  46. 1 resolved
  47. end
  48. # Process color with resource resolution
  49. 1 def process_color(color, required_imports = nil)
  50. 123 return nil unless color.is_a?(String)
  51. # Handle data binding expressions
  52. 122 if color.start_with?('@{') || color.start_with?('${}')
  53. 1 return "Color(android.graphics.Color.parseColor(#{quote(color)}))"
  54. end
  55. # Skip resource resolution if we're in the extraction phase
  56. # (Resources directory doesn't exist yet)
  57. 121 source_directory = cached_config['source_directory'] || 'src/main'
  58. 121 layouts_dir = File.join(cached_source_path, source_directory, cached_config['layouts_directory'] || 'assets/Layouts')
  59. 121 resources_dir = File.join(layouts_dir, 'Resources')
  60. # If Resources directory doesn't exist, we're in extraction phase
  61. # Just return standard color parsing
  62. 121 unless File.exist?(resources_dir)
  63. 119 return "Color(android.graphics.Color.parseColor(#{quote(color)}))"
  64. end
  65. 2 resolved = resolve_color(color, cached_config, cached_source_path)
  66. 2 if resolved&.include?('colorResource')
  67. 2 required_imports&.add(:color_resource)
  68. 2 required_imports&.add(:r_class)
  69. end
  70. 2 resolved
  71. end
  72. 1 private
  73. # Check if a string resource exists in strings.xml
  74. 1 def resolve_string(text, config, source_path)
  75. 1 return quote(text) unless text.is_a?(String)
  76. # Skip if it's a data binding expression
  77. 1 return quote(text) if text.start_with?('@{') || text.start_with?('${')
  78. # Try to find the string in strings.xml
  79. 1 string_key = find_string_key(text, config, source_path)
  80. 1 if string_key
  81. # Return stringResource reference
  82. 1 "stringResource(R.string.#{string_key})"
  83. else
  84. # Return quoted string
  85. quote(text)
  86. end
  87. end
  88. # Check if a color resource exists
  89. 1 def resolve_color(color, config, source_path)
  90. 2 return nil unless color.is_a?(String)
  91. # Skip if it's a data binding expression
  92. 2 return "Color(android.graphics.Color.parseColor(#{quote(color)}))" if color.start_with?('@{') || color.start_with?('${')
  93. # Try to find the color in colors.json
  94. 2 color_key = find_color_key(color, config, source_path)
  95. 2 if color_key
  96. # Return colorResource reference
  97. 2 "colorResource(R.color.#{color_key})"
  98. else
  99. # Return Color.parseColor
  100. "Color(android.graphics.Color.parseColor(#{quote(color)}))"
  101. end
  102. end
  103. 1 private
  104. 1 def cached_strings_data
  105. 1 source_directory = cached_config['source_directory'] || 'src/main'
  106. 1 layouts_dir = File.join(cached_source_path, source_directory, cached_config['layouts_directory'] || 'assets/Layouts')
  107. 1 strings_file = File.join(layouts_dir, 'Resources', 'strings.json')
  108. 1 return {} unless File.exist?(strings_file)
  109. begin
  110. 1 JSON.parse(File.read(strings_file))
  111. rescue JSON::ParserError
  112. {}
  113. end
  114. end
  115. 1 def cached_colors_data
  116. 2 source_directory = cached_config['source_directory'] || 'src/main'
  117. 2 layouts_dir = File.join(cached_source_path, source_directory, cached_config['layouts_directory'] || 'assets/Layouts')
  118. 2 colors_file = File.join(layouts_dir, 'Resources', 'colors.json')
  119. 2 return {} unless File.exist?(colors_file)
  120. begin
  121. 2 JSON.parse(File.read(colors_file))
  122. rescue JSON::ParserError
  123. {}
  124. end
  125. end
  126. 1 def find_string_key(text, config, source_path)
  127. 1 strings_data = cached_strings_data
  128. # Search through all file prefixes for matching values
  129. 1 strings_data.each do |file_prefix, file_strings|
  130. 1 next unless file_strings.is_a?(Hash)
  131. 1 file_strings.each do |key, value|
  132. 1 if value == text
  133. # Return the full key with prefix
  134. 1 return "#{file_prefix}_#{key}"
  135. end
  136. end
  137. end
  138. nil
  139. end
  140. 1 def find_color_key(color, config, source_path)
  141. 2 colors_data = cached_colors_data
  142. # First check if the color itself is a key in colors.json
  143. 2 if colors_data.has_key?(color)
  144. 1 return color
  145. end
  146. # If it's a hex color, normalize and search by value
  147. 1 if color.match?(/^#?[A-Fa-f0-9]{6,8}$/)
  148. 1 normalized_color = normalize_color(color)
  149. # Search through colors by value
  150. 1 colors_data.each do |key, value|
  151. 1 if normalize_color(value) == normalized_color
  152. 1 return key
  153. end
  154. end
  155. end
  156. # Also check colors.xml for predefined Android colors
  157. # These are colors that might be defined in colors.xml but not in colors.json
  158. colors_xml_path = File.join(source_path, config['source_directory'] || 'src/main', 'res/values/colors.xml')
  159. if File.exist?(colors_xml_path)
  160. # Quick check - if the color name exists in colors.xml
  161. # we'll assume it's available (proper check would parse XML)
  162. xml_content = File.read(colors_xml_path)
  163. if xml_content.include?("name='#{color}'") || xml_content.include?("name=\"#{color}\"")
  164. return color
  165. end
  166. end
  167. nil
  168. end
  169. 1 def normalize_color(color)
  170. 2 return nil unless color.is_a?(String)
  171. # Remove # if present and convert to lowercase
  172. 2 color.sub(/^#/, '').downcase
  173. end
  174. 1 def quote(text)
  175. # Escape special characters properly
  176. 228 escaped = text.to_s.gsub('\\', '\\\\\\\\')
  177. .gsub('"', '\\"')
  178. .gsub("\n", '\\n')
  179. .gsub("\r", '\\r')
  180. .gsub("\t", '\\t')
  181. 228 "\"#{escaped}\""
  182. end
  183. end
  184. end
  185. end
  186. end
  187. end

lib/compose/helpers/visibility_helper.rb

100.0% lines covered

31 relevant lines. 31 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module KjuiTools
  3. 1 module Compose
  4. 1 module Helpers
  5. 1 class VisibilityHelper
  6. 1 def self.wrap_with_visibility(json_data, component_code, depth, required_imports)
  7. 65 visibility_result = ModifierBuilder.build_visibility(json_data, required_imports)
  8. 65 visibility_info = visibility_result[:visibility_info]
  9. # If no visibility attributes, return the component as-is
  10. 65 return component_code if visibility_info.empty?
  11. # Build VisibilityWrapper
  12. 5 wrapper_code = indent("VisibilityWrapper(", depth)
  13. # Add visibility parameters
  14. 5 if visibility_info[:visibility_binding]
  15. 1 wrapper_code += "\n" + indent("visibility = #{visibility_info[:visibility_binding]},", depth + 1)
  16. 4 elsif visibility_info[:visibility]
  17. 2 wrapper_code += "\n" + indent("visibility = \"#{visibility_info[:visibility]}\",", depth + 1)
  18. end
  19. 5 if visibility_info[:hidden_binding]
  20. 1 wrapper_code += "\n" + indent("hidden = #{visibility_info[:hidden_binding]},", depth + 1)
  21. 4 elsif visibility_info[:hidden]
  22. 1 wrapper_code += "\n" + indent("hidden = true,", depth + 1)
  23. end
  24. 5 wrapper_code += "\n" + indent(") {", depth)
  25. 5 wrapper_code += "\n" + component_code
  26. 5 wrapper_code += "\n" + indent("}", depth)
  27. 5 wrapper_code
  28. end
  29. 1 def self.should_skip_render?(json_data)
  30. # Check if component should not be rendered at all (static gone/hidden)
  31. 65 return true if json_data['visibility'] == 'gone' && !json_data['visibility'].to_s.include?('@{')
  32. 64 return true if json_data['hidden'] == true && !json_data['hidden'].to_s.include?('@{')
  33. 63 false
  34. end
  35. 1 private
  36. 1 def self.indent(text, level)
  37. 20 return text if level == 0
  38. 5 spaces = ' ' * level
  39. 5 text.split("\n").map { |line|
  40. 5 line.empty? ? line : spaces + line
  41. }.join("\n")
  42. end
  43. end
  44. end
  45. end
  46. end

lib/compose/setup/compose_setup.rb

49.29% lines covered

140 relevant lines. 69 lines covered and 71 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'fileutils'
  3. 1 require 'json'
  4. 1 require_relative '../../core/config_manager'
  5. 1 require_relative '../../core/project_finder'
  6. 1 module KjuiTools
  7. 1 module Compose
  8. 1 module Setup
  9. 1 class ComposeSetup
  10. 1 def initialize(project_file_path = nil)
  11. 12 @project_file_path = project_file_path
  12. 12 @config = Core::ConfigManager.load_config
  13. 12 @source_path = Core::ProjectFinder.get_full_source_path
  14. 12 @package_name = Core::ProjectFinder.package_name
  15. end
  16. 1 def run_full_setup
  17. 2 puts "Setting up Compose project..."
  18. # Create directory structure
  19. 2 create_directory_structure
  20. # Copy base files
  21. 2 copy_base_files
  22. # Create hotloader config
  23. 2 create_hotloader_config
  24. # Setup network security for hot reload
  25. 2 setup_network_security
  26. # Update build.gradle
  27. 2 update_build_gradle
  28. # Create sample layouts
  29. 2 create_sample_layouts
  30. 2 puts "Compose setup complete!"
  31. end
  32. 1 private
  33. 1 def create_directory_structure
  34. 1 puts "Creating directory structure..."
  35. # Get source directory from config
  36. 1 source_dir = @config['source_directory'] || 'src/main'
  37. directories = [
  38. 1 File.join(source_dir, 'assets/Layouts'),
  39. File.join(source_dir, 'assets/Styles'),
  40. package_path('ui/components'),
  41. package_path('ui/theme')
  42. # data, viewmodels, views directories will be created by g view command
  43. ]
  44. 1 directories.each do |dir|
  45. # All paths should be relative to the project root
  46. 4 FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
  47. 4 puts " Created: #{dir}"
  48. end
  49. end
  50. 1 def copy_base_files
  51. puts "Creating base files..."
  52. # Create theme file
  53. create_theme_file
  54. # Create base components
  55. create_base_components
  56. # Create MainActivity with Compose setup
  57. create_main_activity
  58. end
  59. 1 def create_theme_file
  60. theme_path = File.join(package_path('ui/theme'), 'Theme.kt')
  61. content = <<~KOTLIN
  62. package #{@package_name}.ui.theme
  63. import androidx.compose.foundation.isSystemInDarkTheme
  64. import androidx.compose.material3.*
  65. import androidx.compose.runtime.Composable
  66. import androidx.compose.ui.graphics.Color
  67. private val LightColorScheme = lightColorScheme(
  68. primary = Color(0xFF6200EE),
  69. onPrimary = Color.White,
  70. secondary = Color(0xFF03DAC6),
  71. onSecondary = Color.Black,
  72. background = Color(0xFFF5F5F5),
  73. onBackground = Color.Black,
  74. surface = Color.White,
  75. onSurface = Color.Black,
  76. )
  77. private val DarkColorScheme = darkColorScheme(
  78. primary = Color(0xFFBB86FC),
  79. onPrimary = Color.Black,
  80. secondary = Color(0xFF03DAC6),
  81. onSecondary = Color.Black,
  82. background = Color(0xFF121212),
  83. onBackground = Color.White,
  84. surface = Color(0xFF121212),
  85. onSurface = Color.White,
  86. )
  87. @Composable
  88. fun KotlinJsonUITheme(
  89. darkTheme: Boolean = isSystemInDarkTheme(),
  90. content: @Composable () -> Unit
  91. ) {
  92. val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
  93. MaterialTheme(
  94. colorScheme = colorScheme,
  95. typography = Typography(),
  96. content = content
  97. )
  98. }
  99. KOTLIN
  100. File.write(theme_path, content)
  101. puts " Created: Theme.kt"
  102. end
  103. 1 def create_base_components
  104. # Create JsonUILoader component
  105. loader_path = File.join(package_path('ui/components'), 'JsonUILoader.kt')
  106. content = <<~KOTLIN
  107. package #{@package_name}.ui.components
  108. import androidx.compose.runtime.*
  109. import androidx.compose.ui.platform.LocalContext
  110. import kotlinx.coroutines.Dispatchers
  111. import kotlinx.coroutines.withContext
  112. import org.json.JSONObject
  113. /**
  114. * Loads and renders a JSON UI layout
  115. */
  116. @Composable
  117. fun JsonUILoader(
  118. layoutName: String,
  119. onAction: (String) -> Unit = {}
  120. ) {
  121. val context = LocalContext.current
  122. var jsonContent by remember { mutableStateOf<JSONObject?>(null) }
  123. LaunchedEffect(layoutName) {
  124. withContext(Dispatchers.IO) {
  125. try {
  126. val inputStream = context.assets.open("Layouts/$layoutName.json")
  127. val json = inputStream.bufferedReader().use { it.readText() }
  128. jsonContent = JSONObject(json)
  129. } catch (e: Exception) {
  130. e.printStackTrace()
  131. }
  132. }
  133. }
  134. jsonContent?.let { json ->
  135. // Render the JSON UI
  136. JsonUIRenderer(json = json, onAction = onAction)
  137. }
  138. }
  139. @Composable
  140. fun JsonUIRenderer(
  141. json: JSONObject,
  142. onAction: (String) -> Unit = {}
  143. ) {
  144. // TODO: Implement JSON to Compose rendering
  145. // This will be generated by kjui_tools
  146. }
  147. KOTLIN
  148. File.write(loader_path, content)
  149. puts " Created: JsonUILoader.kt"
  150. end
  151. 1 def create_main_activity
  152. source_dir = @config['source_directory'] || 'src/main'
  153. package_dirs = @package_name.gsub('.', '/')
  154. activity_path = File.join(source_dir, "kotlin/#{package_dirs}", 'MainActivity.kt')
  155. content = <<~KOTLIN
  156. package #{@package_name}
  157. import android.os.Bundle
  158. import androidx.activity.ComponentActivity
  159. import androidx.activity.compose.setContent
  160. import androidx.compose.foundation.layout.fillMaxSize
  161. import androidx.compose.material3.*
  162. import androidx.compose.runtime.*
  163. import androidx.compose.ui.Modifier
  164. import #{@package_name}.ui.theme.KotlinJsonUITheme
  165. import #{@package_name}.ui.components.JsonUILoader
  166. class MainActivity : ComponentActivity() {
  167. override fun onCreate(savedInstanceState: Bundle?) {
  168. super.onCreate(savedInstanceState)
  169. setContent {
  170. KotlinJsonUITheme {
  171. Surface(
  172. modifier = Modifier.fillMaxSize(),
  173. color = MaterialTheme.colorScheme.background
  174. ) {
  175. // Load main layout from JSON
  176. JsonUILoader(
  177. layoutName = "main",
  178. onAction = { action ->
  179. handleAction(action)
  180. }
  181. )
  182. }
  183. }
  184. }
  185. }
  186. private fun handleAction(action: String) {
  187. // Handle actions from JSON UI
  188. when (action) {
  189. // Add action handlers here
  190. else -> println("Unknown action: $action")
  191. }
  192. }
  193. }
  194. KOTLIN
  195. File.write(activity_path, content) unless File.exist?(activity_path)
  196. puts " Created: MainActivity.kt" unless File.exist?(activity_path)
  197. end
  198. 1 def update_build_gradle
  199. 1 puts "Updating build.gradle..."
  200. 1 gradle_file = find_app_gradle_file
  201. 1 return unless gradle_file
  202. content = File.read(gradle_file)
  203. # Check if Compose is already configured
  204. unless content.include?('compose')
  205. puts " Adding Compose dependencies to build.gradle..."
  206. # Add compose to buildFeatures
  207. unless content.include?('buildFeatures')
  208. content.gsub!(/android\s*\{/, "android {\n buildFeatures {\n compose = true\n }")
  209. end
  210. # Add compose options
  211. unless content.include?('composeOptions')
  212. content.gsub!(/android\s*\{/, "android {\n composeOptions {\n kotlinCompilerExtensionVersion = \"1.5.7\"\n }")
  213. end
  214. # Add Compose BOM
  215. unless content.include?('androidx.compose:compose-bom')
  216. dependencies_section = content.match(/dependencies\s*\{(.*?)\}/m)
  217. if dependencies_section
  218. new_deps = <<~GRADLE
  219. implementation(platform("androidx.compose:compose-bom:2023.10.01"))
  220. implementation("androidx.compose.ui:ui")
  221. implementation("androidx.compose.ui:ui-tooling-preview")
  222. implementation("androidx.compose.material3:material3")
  223. implementation("androidx.compose.runtime:runtime")
  224. implementation("androidx.activity:activity-compose:1.8.0")
  225. GRADLE
  226. content.gsub!(/dependencies\s*\{/, "dependencies {\n#{new_deps}")
  227. end
  228. end
  229. File.write(gradle_file, content)
  230. puts " Updated build.gradle with Compose dependencies"
  231. else
  232. puts " Compose already configured in build.gradle"
  233. end
  234. end
  235. 1 def create_hotloader_config
  236. puts "Creating hotloader configuration..."
  237. # Determine the correct project directory
  238. project_root = Core::ProjectFinder.project_dir || Dir.pwd
  239. # Check if we're in sample-app
  240. if File.exist?(File.join(project_root, 'sample-app'))
  241. assets_dir = File.join(project_root, 'sample-app', 'src', 'main', 'assets')
  242. else
  243. source_dir = @config['source_directory'] || 'src/main'
  244. assets_dir = File.join(project_root, source_dir, 'assets')
  245. end
  246. FileUtils.mkdir_p(assets_dir)
  247. # Get IP from config or detect it
  248. ip = if @config['hotloader'] && @config['hotloader']['ip']
  249. @config['hotloader']['ip']
  250. else
  251. get_local_ip || '10.0.2.2' # Default to Android emulator IP
  252. end
  253. port = if @config['hotloader'] && @config['hotloader']['port']
  254. @config['hotloader']['port']
  255. else
  256. 8081
  257. end
  258. # Create hotloader.json
  259. hotloader_config_path = File.join(assets_dir, 'hotloader.json')
  260. hotloader_config = {
  261. 'ip' => ip,
  262. 'port' => port,
  263. 'enabled' => false, # Default to disabled for initial setup
  264. 'websocket_endpoint' => "ws://#{ip}:#{port}",
  265. 'http_endpoint' => "http://#{ip}:#{port}"
  266. }
  267. File.write(hotloader_config_path, JSON.pretty_generate(hotloader_config))
  268. puts " Created: hotloader.json (IP: #{ip}:#{port})"
  269. end
  270. 1 def setup_network_security
  271. puts "Setting up network security for hot reload..."
  272. # Determine the correct project directory
  273. project_root = Core::ProjectFinder.project_dir || Dir.pwd
  274. # Check if we're in sample-app
  275. if File.exist?(File.join(project_root, 'sample-app'))
  276. res_dir = File.join(project_root, 'sample-app', 'src', 'main', 'res', 'xml')
  277. debug_dir = File.join(project_root, 'sample-app', 'src', 'debug')
  278. manifest_path = File.join(project_root, 'sample-app', 'src', 'main', 'AndroidManifest.xml')
  279. else
  280. source_dir = @config['source_directory'] || 'src/main'
  281. res_dir = File.join(project_root, source_dir, 'res', 'xml')
  282. debug_dir = File.join(project_root, 'src', 'debug')
  283. manifest_path = File.join(project_root, source_dir, 'AndroidManifest.xml')
  284. end
  285. # Create network security config
  286. FileUtils.mkdir_p(res_dir)
  287. network_config_path = File.join(res_dir, 'network_security_config.xml')
  288. network_config = <<~XML
  289. <?xml version="1.0" encoding="utf-8"?>
  290. <network-security-config>
  291. <!-- Allow cleartext traffic for hot reload development server -->
  292. <domain-config cleartextTrafficPermitted="true">
  293. <!-- Android emulator localhost -->
  294. <domain includeSubdomains="true">10.0.2.2</domain>
  295. <!-- Common local network ranges -->
  296. <domain includeSubdomains="true">localhost</domain>
  297. <domain includeSubdomains="true">127.0.0.1</domain>
  298. <!-- Local network IPs (adjust as needed) -->
  299. <domain includeSubdomains="true">192.168.0.0/16</domain>
  300. <domain includeSubdomains="true">192.168.1.0/24</domain>
  301. <domain includeSubdomains="true">192.168.3.0/24</domain>
  302. <domain includeSubdomains="true">10.0.0.0/8</domain>
  303. </domain-config>
  304. <!-- Default configuration for production -->
  305. <base-config cleartextTrafficPermitted="false">
  306. <trust-anchors>
  307. <certificates src="system" />
  308. </trust-anchors>
  309. </base-config>
  310. </network-security-config>
  311. XML
  312. File.write(network_config_path, network_config)
  313. puts " Created: network_security_config.xml"
  314. # Create debug-specific AndroidManifest.xml with both network config and cleartext traffic
  315. FileUtils.mkdir_p(debug_dir)
  316. debug_manifest_path = File.join(debug_dir, 'AndroidManifest.xml')
  317. debug_manifest = <<~XML
  318. <?xml version="1.0" encoding="utf-8"?>
  319. <manifest xmlns:android="http://schemas.android.com/apk/res/android"
  320. xmlns:tools="http://schemas.android.com/tools">
  321. <!-- Debug-only configuration for hot reload -->
  322. <application
  323. android:networkSecurityConfig="@xml/network_security_config"
  324. android:usesCleartextTraffic="true"
  325. tools:targetApi="31">
  326. </application>
  327. </manifest>
  328. XML
  329. File.write(debug_manifest_path, debug_manifest)
  330. puts " Created: debug/AndroidManifest.xml with cleartext traffic enabled for debug builds only"
  331. end
  332. 1 def get_local_ip
  333. # Try to get WiFi IP first (common interface names)
  334. 1 require 'socket'
  335. 1 Socket.ip_address_list.each do |addr|
  336. 7 if addr.ipv4? && !addr.ipv4_loopback? && !addr.ipv4_multicast?
  337. 1 return addr.ip_address
  338. end
  339. end
  340. nil
  341. rescue
  342. nil
  343. end
  344. 1 def create_sample_layouts
  345. 1 puts "Creating sample layouts..."
  346. # Create main.json
  347. 1 source_dir = @config['source_directory'] || 'src/main'
  348. 1 main_layout = File.join(source_dir, 'assets/Layouts/main.json')
  349. 1 content = <<~JSON
  350. {
  351. "type": "SafeAreaView",
  352. "background": "#FFFFFF",
  353. "child": [
  354. {
  355. "type": "View",
  356. "orientation": "vertical",
  357. "padding": 16,
  358. "child": [
  359. {
  360. "type": "Label",
  361. "text": "Welcome to KotlinJsonUI",
  362. "fontSize": 24,
  363. "fontWeight": "bold",
  364. "fontColor": "#000000",
  365. "marginBottom": 20
  366. },
  367. {
  368. "type": "Label",
  369. "text": "Build native Android UIs with JSON",
  370. "fontSize": 16,
  371. "fontColor": "#666666",
  372. "marginBottom": 30
  373. },
  374. {
  375. "type": "Button",
  376. "text": "Get Started",
  377. "onclick": "getStarted",
  378. "background": "#6200EE",
  379. "fontColor": "#FFFFFF",
  380. "padding": [12, 24],
  381. "cornerRadius": 8
  382. }
  383. ]
  384. }
  385. ],
  386. "data": [
  387. {
  388. "name": "title",
  389. "class": "String",
  390. "defaultValue": "'Welcome'"
  391. }
  392. ]
  393. }
  394. JSON
  395. 1 FileUtils.mkdir_p(File.dirname(main_layout))
  396. 1 File.write(main_layout, content) unless File.exist?(main_layout)
  397. 1 puts " Created: main.json" unless File.exist?(main_layout)
  398. # Create sample style
  399. 1 source_dir = @config['source_directory'] || 'src/main'
  400. 1 button_style = File.join(source_dir, 'assets/Styles/primary_button.json')
  401. 1 style_content = <<~JSON
  402. {
  403. "background": "#6200EE",
  404. "fontColor": "#FFFFFF",
  405. "fontSize": 16,
  406. "fontWeight": "medium",
  407. "padding": [12, 24],
  408. "cornerRadius": 8
  409. }
  410. JSON
  411. 1 FileUtils.mkdir_p(File.dirname(button_style))
  412. 1 File.write(button_style, style_content) unless File.exist?(button_style)
  413. 1 puts " Created: primary_button.json style" unless File.exist?(button_style)
  414. end
  415. 1 def package_path(subpath)
  416. 3 source_dir = @config['source_directory'] || 'src/main'
  417. 3 package_dirs = @package_name.gsub('.', '/')
  418. 3 File.join(source_dir, "kotlin/#{package_dirs}/#{subpath}")
  419. end
  420. 1 def find_app_gradle_file
  421. # Look for app/build.gradle or app/build.gradle.kts
  422. 4 candidates = [
  423. 'app/build.gradle.kts',
  424. 'app/build.gradle',
  425. 'build.gradle.kts',
  426. 'build.gradle'
  427. ]
  428. 4 project_root = Core::ProjectFinder.project_dir || Dir.pwd
  429. 4 candidates.each do |candidate|
  430. 11 path = File.join(project_root, candidate)
  431. 11 return path if File.exist?(path)
  432. end
  433. nil
  434. end
  435. end
  436. end
  437. end
  438. end

lib/compose/style_loader.rb

100.0% lines covered

39 relevant lines. 39 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require_relative '../core/config_manager'
  4. 1 require_relative '../core/project_finder'
  5. 1 module KjuiTools
  6. 1 module Compose
  7. 1 class StyleLoader
  8. 1 class << self
  9. 1 def load_and_merge(json_data)
  10. 37 return json_data unless json_data.is_a?(Hash)
  11. # Load style if specified
  12. 36 if json_data['style']
  13. 4 style_data = load_style(json_data['style'])
  14. 4 if style_data
  15. # Merge style data with component data
  16. # Component data takes precedence over style data
  17. 3 merged_data = style_data.merge(json_data)
  18. # Remove the style key from the merged data
  19. 3 merged_data.delete('style')
  20. 3 json_data = merged_data
  21. end
  22. end
  23. # Process children recursively
  24. 36 if json_data['child']
  25. 11 if json_data['child'].is_a?(Array)
  26. 19 json_data['child'] = json_data['child'].map { |child| load_and_merge(child) }
  27. else
  28. 2 json_data['child'] = load_and_merge(json_data['child'])
  29. end
  30. end
  31. # Process includes
  32. 36 if json_data['include']
  33. 2 json_data = process_include(json_data)
  34. end
  35. 36 json_data
  36. end
  37. 1 private
  38. 1 def load_style(style_name)
  39. 4 config = Core::ConfigManager.load_config
  40. 4 project_path = Core::ProjectFinder.get_full_source_path || Dir.pwd
  41. 4 source_dir = config['source_directory'] || 'src/main'
  42. 4 source_path = File.join(project_path, source_dir)
  43. 4 styles_dir = File.join(source_path, config['styles_directory'] || 'assets/Styles')
  44. 4 style_file = File.join(styles_dir, "#{style_name}.json")
  45. 4 return nil unless File.exist?(style_file)
  46. begin
  47. 4 style_content = File.read(style_file)
  48. 4 style_data = JSON.parse(style_content)
  49. # Recursively load and merge styles in the style file
  50. 3 load_and_merge(style_data)
  51. 1 rescue JSON::ParserError => e
  52. 1 puts "Warning: Failed to parse style file #{style_file}: #{e.message}"
  53. 1 nil
  54. end
  55. end
  56. 1 def process_include(json_data)
  57. # For Compose generation, don't expand includes inline
  58. # They should be handled as component calls in compose_builder
  59. 2 json_data
  60. end
  61. end
  62. end
  63. end
  64. end

lib/core/attribute_validator.rb

67.61% lines covered

142 relevant lines. 96 lines covered and 46 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 module KjuiTools
  4. 1 module Core
  5. # Validates JSON component attributes against defined schemas
  6. # Used by both XML and Compose converters
  7. 1 class AttributeValidator
  8. 1 attr_reader :definitions, :warnings
  9. 1 attr_accessor :mode
  10. # Valid modes
  11. 1 MODES = [:xml, :compose, :dynamic, :all].freeze
  12. 1 def initialize(mode = :all)
  13. 41 @definitions = load_definitions
  14. 41 @warnings = []
  15. 41 @mode = mode
  16. end
  17. # Validate a component and return warnings
  18. # @param component [Hash] The component to validate
  19. # @param component_type [String] The type of component (e.g., "Text", "TextField")
  20. # @return [Array<String>] Array of warning messages
  21. 1 def validate(component, component_type = nil)
  22. 38 @warnings = []
  23. 38 type = component_type || component['type']
  24. 38 return @warnings unless type
  25. # Get valid attributes for this component type
  26. 38 valid_attrs = get_valid_attributes(type)
  27. # Check each attribute in the component
  28. 38 component.each do |key, value|
  29. 122 next if key == 'type' || key == 'child' || key == 'children'
  30. 82 if valid_attrs.key?(key)
  31. 78 attr_def = valid_attrs[key]
  32. # Check mode compatibility first
  33. 78 if mode_compatible?(attr_def)
  34. # Validate attribute value
  35. 78 validate_attribute(key, value, attr_def, type)
  36. else
  37. # Attribute not supported in current mode
  38. add_mode_warning(key, attr_def, type)
  39. end
  40. else
  41. # Unknown attribute
  42. 4 add_warning("Unknown attribute '#{key}' for component type '#{type}'")
  43. end
  44. end
  45. # Check for required attributes
  46. 38 valid_attrs.each do |attr_name, attr_def|
  47. 2478 if attr_def['required'] && !component.key?(attr_name)
  48. 1 add_warning("Required attribute '#{attr_name}' is missing for component type '#{type}'")
  49. end
  50. end
  51. 38 @warnings
  52. end
  53. # Print all warnings to console
  54. 1 def print_warnings
  55. @warnings.each do |warning|
  56. puts "\e[33m⚠️ [KJUI Warning] #{warning}\e[0m"
  57. end
  58. end
  59. # Check if there are any warnings
  60. 1 def has_warnings?
  61. 2 !@warnings.empty?
  62. end
  63. 1 private
  64. 1 def load_definitions
  65. 41 definitions_path = File.join(File.dirname(__FILE__), 'attribute_definitions.json')
  66. 41 if File.exist?(definitions_path)
  67. 41 JSON.parse(File.read(definitions_path))
  68. else
  69. puts "\e[31m[KJUI Error] attribute_definitions.json not found at #{definitions_path}\e[0m"
  70. {}
  71. end
  72. end
  73. # Get valid attributes for a component type (common + type-specific)
  74. 1 def get_valid_attributes(type)
  75. 38 attrs = {}
  76. # Add common attributes
  77. 38 attrs.merge!(@definitions['common'] || {})
  78. # Map component type to definition key
  79. 38 def_key = map_type_to_definition(type)
  80. # Add type-specific attributes
  81. 38 if @definitions[def_key]
  82. 38 attrs.merge!(@definitions[def_key])
  83. end
  84. 38 attrs
  85. end
  86. # Map JSON type to definition key
  87. 1 def map_type_to_definition(type)
  88. 38 case type
  89. when 'Label', 'Text'
  90. 10 'Text'
  91. when 'TextField', 'EditText'
  92. 3 'TextField'
  93. when 'TextView', 'MultiLineEditText'
  94. 'TextView'
  95. when 'Button'
  96. 1 'Button'
  97. when 'Image', 'ImageView'
  98. 1 'Image'
  99. when 'NetworkImage', 'NetworkImageView'
  100. 'NetworkImage'
  101. when 'CircleImage', 'CircleImageView'
  102. 'CircleImage'
  103. when 'SelectBox', 'Spinner', 'DatePicker'
  104. 1 'SelectBox'
  105. when 'Toggle', 'Switch'
  106. 2 'Switch'
  107. when 'CheckBox'
  108. 'CheckBox'
  109. when 'Radio', 'RadioButton', 'RadioGroup'
  110. 1 'Radio'
  111. when 'Segment', 'SegmentedControl', 'TabLayout'
  112. 'Segment'
  113. when 'Slider', 'SeekBar'
  114. 'Slider'
  115. when 'Progress', 'ProgressBar'
  116. 'Progress'
  117. when 'Indicator', 'ActivityIndicator'
  118. 'Indicator'
  119. when 'View', 'Container', 'SafeAreaView', 'LinearLayout', 'RelativeLayout', 'FrameLayout',
  120. 'VStack', 'HStack', 'ZStack', 'Column', 'Row', 'Box'
  121. 16 'View'
  122. when 'ScrollView', 'Scroll'
  123. 1 'ScrollView'
  124. when 'Collection', 'CollectionView', 'RecyclerView', 'LazyGrid', 'Grid'
  125. 2 'Collection'
  126. when 'Table', 'TableView', 'ListView', 'LazyColumn'
  127. 'Table'
  128. when 'GradientView'
  129. 'GradientView'
  130. when 'Web', 'WebView'
  131. 'Web'
  132. when 'TabView'
  133. 'TabView'
  134. when 'ConstraintLayout'
  135. 'View'
  136. else
  137. type
  138. end
  139. end
  140. # Validate a single attribute value
  141. 1 def validate_attribute(name, value, definition, component_type, path = nil)
  142. 78 return unless definition
  143. 78 current_path = path ? "#{path}.#{name}" : name
  144. # Skip validation for binding expressions
  145. 78 if value.is_a?(String) && value.include?('@{')
  146. 1 return
  147. end
  148. # Check type
  149. 77 expected_types = Array(definition['type'])
  150. 77 actual_type = get_value_type(value)
  151. 77 unless type_matches?(actual_type, expected_types, value)
  152. 1 add_warning("Attribute '#{current_path}' in '#{component_type}' expects #{expected_types.join(' or ')}, got #{actual_type}")
  153. 1 return # Don't validate nested properties if type is wrong
  154. end
  155. # Check enum values
  156. 76 if definition['enum'] && !definition['enum'].include?(value)
  157. 2 add_warning("Attribute '#{current_path}' in '#{component_type}' has invalid value '#{value}'. Valid values: #{definition['enum'].join(', ')}")
  158. end
  159. # Check min/max for numbers
  160. 76 if actual_type == 'number'
  161. 29 if definition['min'] && value < definition['min']
  162. 1 add_warning("Attribute '#{current_path}' in '#{component_type}' value #{value} is less than minimum #{definition['min']}")
  163. end
  164. 29 if definition['max'] && value > definition['max']
  165. 1 add_warning("Attribute '#{current_path}' in '#{component_type}' value #{value} is greater than maximum #{definition['max']}")
  166. end
  167. end
  168. # Validate nested object properties
  169. 76 if actual_type == 'object' && definition['properties']
  170. validate_nested_object(value, definition['properties'], component_type, current_path)
  171. end
  172. # Validate array items
  173. 76 if actual_type == 'array' && definition['items']
  174. validate_array_items(value, definition['items'], component_type, current_path)
  175. end
  176. end
  177. # Validate nested object properties
  178. 1 def validate_nested_object(obj, properties, component_type, path)
  179. return unless obj.is_a?(Hash)
  180. obj.each do |key, value|
  181. if properties.key?(key)
  182. validate_attribute(key, value, properties[key], component_type, path)
  183. else
  184. add_warning("Unknown property '#{path}.#{key}' in '#{component_type}'")
  185. end
  186. end
  187. end
  188. # Validate array items
  189. 1 def validate_array_items(arr, item_def, component_type, path)
  190. return unless arr.is_a?(Array)
  191. arr.each_with_index do |item, index|
  192. item_path = "#{path}[#{index}]"
  193. if item_def['type'] == 'object' && item_def['properties']
  194. if item.is_a?(Hash)
  195. validate_nested_object(item, item_def['properties'], component_type, item_path)
  196. else
  197. add_warning("#{item_path} in '#{component_type}' expects object, got #{get_value_type(item)}")
  198. end
  199. else
  200. # Simple type validation for array items
  201. expected_types = Array(item_def['type'])
  202. actual_type = get_value_type(item)
  203. unless type_matches?(actual_type, expected_types, item)
  204. add_warning("#{item_path} in '#{component_type}' expects #{expected_types.join(' or ')}, got #{actual_type}")
  205. end
  206. end
  207. end
  208. end
  209. 1 def get_value_type(value)
  210. 77 case value
  211. when String
  212. 37 'string'
  213. when Integer, Float
  214. 29 'number'
  215. when TrueClass, FalseClass
  216. 8 'boolean'
  217. when Array
  218. 3 'array'
  219. when Hash
  220. 'object'
  221. when NilClass
  222. 'null'
  223. else
  224. 'unknown'
  225. end
  226. end
  227. 1 def type_matches?(actual, expected_types, value)
  228. 77 expected_types.any? do |expected|
  229. 81 case expected
  230. when 'string'
  231. 36 actual == 'string'
  232. when 'number'
  233. 34 actual == 'number'
  234. when 'boolean'
  235. 8 actual == 'boolean'
  236. when 'array'
  237. 3 actual == 'array'
  238. when 'object'
  239. actual == 'object'
  240. else
  241. # For union types or special cases
  242. actual == expected
  243. end
  244. end
  245. end
  246. 1 def add_warning(message)
  247. 10 @warnings << message unless @warnings.include?(message)
  248. end
  249. # Check if attribute is compatible with current mode
  250. 1 def mode_compatible?(attr_def)
  251. 78 return true if @mode == :all
  252. 4 return true unless attr_def['mode']
  253. 2 attr_modes = Array(attr_def['mode'])
  254. 2 attr_modes.include?(@mode.to_s) || attr_modes.include?('all')
  255. end
  256. # Add warning for mode-incompatible attribute
  257. 1 def add_mode_warning(attr_name, attr_def, component_type)
  258. attr_modes = Array(attr_def['mode'])
  259. mode_str = attr_modes.map { |m| m.capitalize }.join('/')
  260. current_mode_str = @mode.to_s.capitalize
  261. add_warning("Attribute '#{attr_name}' in '#{component_type}' is only supported in #{mode_str} mode (current: #{current_mode_str})")
  262. end
  263. end
  264. end
  265. end

lib/core/config_manager.rb

98.86% lines covered

88 relevant lines. 87 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'pathname'
  4. 1 module KjuiTools
  5. 1 module Core
  6. 1 class ConfigManager
  7. 1 CONFIG_FILE = 'kjui.config.json'
  8. DEFAULT_CONFIG = {
  9. 1 'mode' => 'compose',
  10. 'project_name' => '',
  11. 'package_name' => 'com.example.app',
  12. 'source_directory' => 'app/src/main',
  13. 'layouts_directory' => 'assets/Layouts',
  14. 'styles_directory' => 'assets/Styles',
  15. 'view_directory' => 'kotlin/com/example/app/views',
  16. 'data_directory' => 'kotlin/com/example/app/data',
  17. 'viewmodel_directory' => 'kotlin/com/example/app/viewmodels',
  18. 'extension_directory' => 'library/src/main/kotlin/com/kotlinjsonui/extensions',
  19. 'adapter_directory' => 'library/src/main/kotlin/com/kotlinjsonui/adapters',
  20. 'custom_view_types' => {},
  21. 'compose' => {
  22. 'output_directory' => 'kotlin/com/example/app/generated'
  23. },
  24. 'xml' => {
  25. 'bindings_directory' => 'java/com/example/app/bindings'
  26. }
  27. }.freeze
  28. 1 class << self
  29. 1 def load_config
  30. 129 config_path = find_config_file
  31. 129 base_config = if config_path && File.exist?(config_path)
  32. begin
  33. 128 config_data = JSON.parse(File.read(config_path))
  34. # Store the config directory for use by generators
  35. 127 config_data['_config_dir'] = File.dirname(config_path)
  36. 127 config_data
  37. rescue JSON::ParserError => e
  38. 1 puts "Error parsing config file: #{e.message}"
  39. 1 {}
  40. end
  41. else
  42. 1 {}
  43. end
  44. # Merge with default config to ensure all keys exist
  45. 129 deep_merge(DEFAULT_CONFIG, base_config)
  46. end
  47. # Find config file in project
  48. 1 def find_config_file
  49. # First check current directory
  50. 132 return CONFIG_FILE if File.exist?(CONFIG_FILE)
  51. # Check subdirectories for kjui.config.json
  52. 3 Dir.glob(File.join(Dir.pwd, '**/kjui.config.json')).each do |config_path|
  53. # Skip hidden directories and node_modules
  54. 1 next if config_path.include?('/.') || config_path.include?('/node_modules/')
  55. 1 return config_path
  56. end
  57. # Check parent directories up to 3 levels
  58. 2 current = Dir.pwd
  59. 2 3.times do
  60. 6 current = File.dirname(current)
  61. 6 config_path = File.join(current, CONFIG_FILE)
  62. 6 return config_path if File.exist?(config_path)
  63. end
  64. nil
  65. end
  66. 1 def save_config(config)
  67. 3 File.write(CONFIG_FILE, JSON.pretty_generate(config))
  68. end
  69. # Deep merge two hashes
  70. 1 def deep_merge(hash1, hash2)
  71. 452 hash1.merge(hash2) do |key, old_val, new_val|
  72. 1774 if old_val.is_a?(Hash) && new_val.is_a?(Hash)
  73. 321 deep_merge(old_val, new_val)
  74. else
  75. 1453 new_val
  76. end
  77. end
  78. end
  79. 1 def config_exists?
  80. 2 File.exist?(CONFIG_FILE)
  81. end
  82. 1 def get(key, default = nil)
  83. 21 config = load_config
  84. 21 keys = key.split('.')
  85. 21 value = config
  86. 21 keys.each do |k|
  87. 22 value = value[k] if value.is_a?(Hash)
  88. end
  89. 21 value || default
  90. end
  91. 1 def set(key, value)
  92. 2 config = load_config
  93. 2 keys = key.split('.')
  94. 2 current = config
  95. 2 keys[0...-1].each do |k|
  96. 1 current[k] ||= {}
  97. 1 current = current[k]
  98. end
  99. 2 current[keys.last] = value
  100. 2 save_config(config)
  101. end
  102. 1 def detect_mode
  103. # Check for Android project files
  104. 7 gradle_files = Dir.glob('build.gradle*')
  105. 7 settings_gradle = Dir.glob('settings.gradle*')
  106. 7 if gradle_files.any? || settings_gradle.any?
  107. # Check if it's a Compose project
  108. 2 build_file = gradle_files.first
  109. 2 if build_file && File.exist?(build_file)
  110. 2 content = File.read(build_file)
  111. 2 if content.include?('compose') || content.include?('androidx.compose')
  112. 1 return 'compose'
  113. end
  114. end
  115. # Default to XML for Android projects
  116. 1 return 'xml'
  117. end
  118. # Default mode
  119. 5 'all'
  120. end
  121. 1 def project_type
  122. 4 mode = get('mode', detect_mode)
  123. 4 case mode
  124. when 'compose'
  125. 1 'Jetpack Compose'
  126. when 'xml'
  127. 1 'Android XML'
  128. when 'all'
  129. 1 'Android (XML + Compose)'
  130. else
  131. 1 'Unknown'
  132. end
  133. end
  134. 1 def source_path
  135. 7 get('source_directory', 'app/src/main')
  136. end
  137. 1 def layouts_path
  138. 1 Pathname.new(source_path).join(get('layouts_directory', 'assets/Layouts'))
  139. end
  140. 1 def styles_path
  141. 1 Pathname.new(source_path).join(get('styles_directory', 'assets/Styles'))
  142. end
  143. 1 def view_path
  144. 1 Pathname.new(source_path).join(get('view_directory', 'java/com/example/app/ui'))
  145. end
  146. 1 def data_path
  147. 1 Pathname.new(source_path).join(get('data_directory', 'java/com/example/app/data'))
  148. end
  149. 1 def viewmodel_path
  150. 1 Pathname.new(source_path).join(get('viewmodel_directory', 'java/com/example/app/viewmodel'))
  151. end
  152. 1 def generated_path
  153. 1 if get('mode') == 'compose'
  154. 1 compose_config = get('compose', {})
  155. 1 Pathname.new(source_path).join(compose_config['output_directory'] || 'java/com/example/app/generated')
  156. else
  157. Pathname.new(source_path).join(get('bindings_directory', 'java/com/example/app/bindings'))
  158. end
  159. end
  160. end
  161. end
  162. end
  163. end

lib/core/json_loader.rb

100.0% lines covered

17 relevant lines. 17 lines covered and 0 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 class JsonLoader
  4. 1 def initialize(config)
  5. 32 @config = config
  6. end
  7. 1 def load_layout(layout_name)
  8. 10 layout_file = find_layout_file(layout_name)
  9. 10 if layout_file && File.exist?(layout_file)
  10. 8 File.read(layout_file)
  11. else
  12. nil
  13. end
  14. end
  15. 1 def load_json(file_path)
  16. 2 if File.exist?(file_path)
  17. 1 File.read(file_path)
  18. else
  19. nil
  20. end
  21. end
  22. 1 private
  23. 1 def find_layout_file(layout_name)
  24. # Remove .json extension if present
  25. 10 layout_name = layout_name.sub(/\.json$/, '')
  26. 10 project_path = @config['project_path'] || Dir.pwd
  27. # Check multiple possible locations
  28. possible_paths = [
  29. 10 File.join(project_path, 'src', 'main', 'assets', 'Layouts', "#{layout_name}.json"),
  30. File.join(project_path, 'app', 'src', 'main', 'assets', 'Layouts', "#{layout_name}.json"),
  31. File.join(project_path, 'Layouts', "#{layout_name}.json"),
  32. File.join(project_path, "#{layout_name}.json")
  33. ]
  34. 32 possible_paths.find { |path| File.exist?(path) }
  35. end
  36. end

lib/core/logger.rb

100.0% lines covered

16 relevant lines. 16 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module KjuiTools
  3. 1 module Core
  4. 1 class Logger
  5. 1 class << self
  6. 1 def info(message)
  7. 69 puts " #{message}"
  8. end
  9. 1 def success(message)
  10. 3 puts "✅ #{message}"
  11. end
  12. 1 def error(message)
  13. 2 puts "❌ #{message}"
  14. end
  15. 1 def warn(message)
  16. 13 puts "⚠️ #{message}"
  17. end
  18. 1 def debug(message)
  19. 59 puts "🔍 #{message}" if ENV['DEBUG']
  20. end
  21. 1 def newline
  22. 1 puts
  23. end
  24. end
  25. end
  26. end
  27. end

lib/core/project_finder.rb

93.22% lines covered

59 relevant lines. 55 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'pathname'
  3. 1 require 'find'
  4. 1 module KjuiTools
  5. 1 module Core
  6. 1 class ProjectFinder
  7. 1 class << self
  8. 1 attr_accessor :project_dir, :project_file_path
  9. 1 def setup_paths
  10. 1 @project_dir = find_project_dir
  11. 1 @project_file_path = find_project_file
  12. end
  13. 1 def find_project_dir
  14. # Look for Android project indicators
  15. 4 current_dir = Dir.pwd
  16. # Check current directory
  17. 4 return current_dir if android_project?(current_dir)
  18. # Check parent directory
  19. 3 parent_dir = File.dirname(current_dir)
  20. 3 return parent_dir if android_project?(parent_dir)
  21. # Default to current directory
  22. 2 current_dir
  23. end
  24. 1 def find_project_file
  25. # Look for main build.gradle or build.gradle.kts
  26. 4 gradle_files = Dir.glob('build.gradle*')
  27. 4 return gradle_files.first if gradle_files.any?
  28. # Check parent directory
  29. 2 parent_gradle = Dir.glob('../build.gradle*')
  30. 2 return File.expand_path(parent_gradle.first) if parent_gradle.any?
  31. nil
  32. end
  33. 1 def find_source_directory
  34. # Common Android source directory patterns
  35. 3 common_paths = [
  36. 'app/src/main',
  37. 'src/main',
  38. 'src',
  39. 'app'
  40. ]
  41. 3 project_root = @project_dir || Dir.pwd
  42. 3 common_paths.each do |path|
  43. 7 full_path = File.join(project_root, path)
  44. 7 return path if Dir.exist?(full_path)
  45. end
  46. # Try to find any src directory
  47. 1 Find.find(project_root) do |path|
  48. 1 if File.directory?(path) && File.basename(path) == 'src'
  49. # Check if it contains main directory
  50. main_path = File.join(path, 'main')
  51. if Dir.exist?(main_path)
  52. return Pathname.new(main_path).relative_path_from(Pathname.new(project_root)).to_s
  53. end
  54. return Pathname.new(path).relative_path_from(Pathname.new(project_root)).to_s
  55. end
  56. end
  57. # Default
  58. 1 'app/src/main'
  59. end
  60. 1 def get_full_source_path
  61. 52 @project_dir || Dir.pwd
  62. end
  63. 1 def get_package_name
  64. 1 package_name
  65. end
  66. 1 def package_name
  67. # Try to detect package name from AndroidManifest.xml
  68. 4 manifest_paths = [
  69. 'app/src/main/AndroidManifest.xml',
  70. 'src/main/AndroidManifest.xml',
  71. 'AndroidManifest.xml'
  72. ]
  73. 4 project_root = @project_dir || Dir.pwd
  74. 4 manifest_paths.each do |path|
  75. 10 full_path = File.join(project_root, path)
  76. 10 if File.exist?(full_path)
  77. 1 content = File.read(full_path)
  78. # Extract package name from manifest
  79. 1 if content =~ /package="([^"]+)"/
  80. 1 return $1
  81. end
  82. end
  83. end
  84. # Try to detect from build.gradle
  85. 3 gradle_files = Dir.glob('**/build.gradle*')
  86. 3 gradle_files.each do |gradle_file|
  87. 2 content = File.read(gradle_file)
  88. # Look for namespace first (more reliable)
  89. 2 if content =~ /namespace\s*=\s*["']([^"']+)["']/
  90. 1 return $1
  91. end
  92. # Look for applicationId
  93. 1 if content =~ /applicationId\s*=\s*["']([^"']+)["']/
  94. 1 return $1
  95. end
  96. end
  97. # Default package name
  98. 1 'com.example.app'
  99. end
  100. 1 private
  101. 1 def android_project?(dir)
  102. # Check for Android project indicators
  103. 7 indicators = [
  104. 'build.gradle',
  105. 'build.gradle.kts',
  106. 'settings.gradle',
  107. 'settings.gradle.kts',
  108. 'gradlew',
  109. 'app/build.gradle',
  110. 'app/build.gradle.kts'
  111. ]
  112. 46 indicators.any? { |indicator| File.exist?(File.join(dir, indicator)) }
  113. end
  114. end
  115. end
  116. end
  117. end

lib/core/resources/color_manager.rb

82.39% lines covered

318 relevant lines. 262 lines covered and 56 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require 'rexml/document'
  5. 1 require 'rexml/formatters/pretty'
  6. 1 require_relative '../logger'
  7. 1 module KjuiTools
  8. 1 module Core
  9. 1 module Resources
  10. 1 class ColorManager
  11. 1 def initialize(config, source_path, resources_dir)
  12. 61 @config = config
  13. 61 @source_path = source_path
  14. 61 @resources_dir = resources_dir
  15. 61 @colors_file = File.join(@resources_dir, 'colors.json')
  16. 61 @defined_colors_file = File.join(@resources_dir, 'defined_colors.json')
  17. 61 @extracted_colors = {}
  18. 61 @undefined_colors = {}
  19. 61 @colors_data = load_colors_json
  20. 61 @defined_colors_data = load_defined_colors_json
  21. end
  22. # Main process method called from ResourcesManager
  23. 1 def process_colors(processed_files, processed_count, skipped_count)
  24. 8 return if processed_files.empty?
  25. 7 Core::Logger.info "Extracting colors from #{processed_count} files (#{skipped_count} skipped)..."
  26. # Extract colors from JSON files
  27. 7 extract_colors(processed_files)
  28. # Save updated colors.json if there are new colors
  29. 7 save_colors_json if @extracted_colors.any?
  30. # Save undefined colors to defined_colors.json
  31. 7 save_defined_colors_json if @undefined_colors.any?
  32. # Generate ColorManager.kt if needed
  33. # Disabled: ColorManager.kt generation is not needed
  34. # generate_color_manager_kotlin if @config['resource_manager_directory']
  35. end
  36. # Apply extracted colors to color resources
  37. 1 def apply_to_color_assets
  38. # Save any pending colors to colors.json
  39. 8 save_colors_json if @extracted_colors.any?
  40. # Save undefined colors to defined_colors.json
  41. 8 save_defined_colors_json if @undefined_colors.any?
  42. # Apply colors to Android colors.xml
  43. 8 apply_to_colors_xml
  44. end
  45. 1 private
  46. # Load existing colors.json file
  47. 1 def load_colors_json
  48. 69 return {} unless File.exist?(@colors_file)
  49. begin
  50. 6 JSON.parse(File.read(@colors_file))
  51. 1 rescue JSON::ParserError => e
  52. 1 Core::Logger.warn "Failed to parse colors.json: #{e.message}"
  53. 1 {}
  54. end
  55. end
  56. # Load existing defined_colors.json file
  57. 1 def load_defined_colors_json
  58. 69 return {} unless File.exist?(@defined_colors_file)
  59. begin
  60. 2 JSON.parse(File.read(@defined_colors_file))
  61. 1 rescue JSON::ParserError => e
  62. 1 Core::Logger.warn "Failed to parse defined_colors.json: #{e.message}"
  63. 1 {}
  64. end
  65. end
  66. # Save colors data to colors.json
  67. 1 def save_colors_json
  68. # Merge extracted colors with existing colors
  69. 1 @colors_data.merge!(@extracted_colors)
  70. # Ensure Resources directory exists
  71. 1 FileUtils.mkdir_p(@resources_dir)
  72. # Write colors.json
  73. 1 File.write(@colors_file, JSON.pretty_generate(@colors_data))
  74. 1 Core::Logger.info "Updated colors.json with #{@extracted_colors.size} new colors"
  75. # Clear extracted colors after saving
  76. 1 @extracted_colors.clear
  77. end
  78. # Apply colors to Android colors.xml file
  79. 1 def apply_to_colors_xml
  80. 8 colors_xml_path = File.join(@source_path, @config['source_directory'] || 'src/main', 'res/values/colors.xml')
  81. 8 unless File.exist?(colors_xml_path)
  82. 7 Core::Logger.info "colors.xml not found at: #{colors_xml_path}, creating new file"
  83. # Ensure the directory exists
  84. 7 colors_dir = File.dirname(colors_xml_path)
  85. 7 FileUtils.mkdir_p(colors_dir)
  86. # Create a new colors.xml with basic structure
  87. 7 default_xml = <<~XML
  88. <?xml version="1.0" encoding="utf-8"?>
  89. <resources>
  90. </resources>
  91. XML
  92. 7 File.write(colors_xml_path, default_xml)
  93. end
  94. # Load colors from colors.json
  95. 8 all_colors = load_colors_json
  96. # Also include defined colors
  97. 8 defined_colors = load_defined_colors_json
  98. 8 all_colors.merge!(defined_colors) if defined_colors.any?
  99. 8 return if all_colors.empty?
  100. # Read and parse existing colors.xml
  101. 2 xml_content = File.read(colors_xml_path)
  102. 2 doc = REXML::Document.new(xml_content)
  103. 2 resources = doc.root
  104. 2 unless resources
  105. Core::Logger.error "Invalid colors.xml structure"
  106. return
  107. end
  108. # Build a hash of existing colors for faster lookup
  109. 2 existing_colors = {}
  110. 2 resources.elements.each('color') do |elem|
  111. 1 name = elem.attributes['name']
  112. 1 existing_colors[name] = elem if name
  113. end
  114. # Add or update colors
  115. 2 colors_added = 0
  116. 2 colors_updated = 0
  117. 2 all_colors.each do |key, value|
  118. # Skip if value is nil or not a hex color
  119. 2 next unless value && value.is_a?(String) && value.match?(/^#?[A-Fa-f0-9]{6,8}$/)
  120. # Normalize hex color (ensure it has # and is uppercase)
  121. 2 hex_value = value.start_with?('#') ? value.upcase : "##{value.upcase}"
  122. # Convert 6-digit hex to 8-digit ARGB format if needed (Android requires ARGB)
  123. 2 if hex_value.length == 7 # #RRGGBB
  124. 2 hex_value = "#FF#{hex_value[1..-1]}" # Add full opacity
  125. end
  126. 2 if existing_colors[key]
  127. # Update existing color if value is different
  128. current_value = existing_colors[key].text
  129. if current_value != hex_value
  130. existing_colors[key].text = hex_value
  131. colors_updated += 1
  132. end
  133. else
  134. # Add new color element
  135. 2 color_elem = REXML::Element.new('color')
  136. 2 color_elem.add_attribute('name', key)
  137. 2 color_elem.text = hex_value
  138. 2 resources.add_element(color_elem)
  139. 2 colors_added += 1
  140. end
  141. end
  142. 2 if colors_added > 0 || colors_updated > 0
  143. # Format and write back
  144. 2 formatter = REXML::Formatters::Pretty.new(2)
  145. 2 formatter.compact = true
  146. 2 output = String.new
  147. 2 formatter.write(doc, output)
  148. 2 File.write(colors_xml_path, output)
  149. 2 Core::Logger.info "Updated colors.xml: #{colors_added} added, #{colors_updated} updated"
  150. end
  151. end
  152. # Save undefined colors to defined_colors.json
  153. 1 def save_defined_colors_json
  154. # Merge new undefined colors with existing defined colors
  155. @defined_colors_data.merge!(@undefined_colors)
  156. # Ensure Resources directory exists
  157. FileUtils.mkdir_p(@resources_dir)
  158. # Write defined_colors.json
  159. File.write(@defined_colors_file, JSON.pretty_generate(@defined_colors_data))
  160. Core::Logger.info "Updated defined_colors.json with #{@undefined_colors.size} undefined color keys"
  161. # Clear undefined colors after saving
  162. @undefined_colors.clear
  163. end
  164. # Extract color values from processed JSON files
  165. 1 def extract_colors(processed_files)
  166. 7 @modified_files = []
  167. 7 Core::Logger.debug "Processing #{processed_files.size} files for colors"
  168. 7 processed_files.each do |json_file|
  169. begin
  170. 7 Core::Logger.debug "Processing file: #{json_file}"
  171. 7 content = File.read(json_file)
  172. 7 data = JSON.parse(content)
  173. # Extract and replace colors recursively from JSON structure
  174. 7 modified = replace_colors_recursive(data)
  175. 7 Core::Logger.debug "File modified: #{modified}, extracted colors: #{@extracted_colors.size}"
  176. # Save modified JSON file if any colors were replaced
  177. 7 if modified
  178. 1 File.write(json_file, JSON.pretty_generate(data))
  179. 1 @modified_files << json_file
  180. 1 Core::Logger.debug "Updated colors in: #{json_file}"
  181. end
  182. rescue JSON::ParserError => e
  183. Core::Logger.warn "Failed to parse #{json_file}: #{e.message}"
  184. rescue => e
  185. Core::Logger.error "Error processing #{json_file}: #{e.message}"
  186. end
  187. end
  188. 7 if @modified_files.any?
  189. 1 Core::Logger.info "Replaced colors in #{@modified_files.size} files"
  190. end
  191. end
  192. # Replace colors recursively in JSON data
  193. 1 def replace_colors_recursive(data, parent_key = nil)
  194. 20 modified = false
  195. 20 case data
  196. when Hash
  197. 15 data.each do |key, value|
  198. # Check if this key is a color property and value is a string
  199. 24 if is_color_property?(key) && value.is_a?(String)
  200. # Process and replace the color value (hex or string key)
  201. 4 new_value = process_and_replace_color(value)
  202. 4 if new_value != value
  203. 4 data[key] = new_value
  204. 4 modified = true
  205. 4 Core::Logger.debug "Replaced #{value} with #{new_value} in #{key}"
  206. end
  207. 20 elsif value.is_a?(Hash) || value.is_a?(Array)
  208. # Recurse into nested structures
  209. 5 child_modified = replace_colors_recursive(value, key)
  210. 5 modified ||= child_modified
  211. end
  212. end
  213. when Array
  214. 5 data.each_with_index do |item, index|
  215. 4 if item.is_a?(Hash) || item.is_a?(Array)
  216. 4 child_modified = replace_colors_recursive(item, parent_key)
  217. 4 modified ||= child_modified
  218. end
  219. end
  220. end
  221. 20 modified
  222. end
  223. # Check if a property name is likely to contain a color
  224. 1 def is_color_property?(key)
  225. # Based on actual XML style_mapper.rb and Compose components
  226. 39 color_properties = [
  227. # Common background/appearance (style_mapper.rb)
  228. 'background',
  229. 'backgroundColor',
  230. 'borderColor',
  231. 'strokeColor',
  232. # Text colors (text_mapper.rb)
  233. 'fontColor',
  234. 'textColor',
  235. 'color', # Generic color that can map to textColor or tint
  236. # State-specific backgrounds (drawable generation)
  237. 'disabledBackground',
  238. 'tapBackground',
  239. 'pressedBackground',
  240. 'selectedBackground',
  241. 'focusedBackground',
  242. 'checkedBackground',
  243. 'rippleColor',
  244. # Input/SelectBox specific (input_mapper.rb, SelectBox component)
  245. 'hintColor',
  246. 'cancelButtonBackgroundColor',
  247. 'cancelButtonTextColor',
  248. # Image/Icon tinting
  249. 'tint',
  250. 'tintColor',
  251. # Gradient colors (style_mapper.rb)
  252. 'gradientStartColor',
  253. 'startColor',
  254. 'gradientEndColor',
  255. 'endColor',
  256. 'gradientCenterColor',
  257. 'centerColor',
  258. # Blur overlay
  259. 'blurOverlayColor',
  260. # Shadow
  261. 'shadowColor'
  262. ]
  263. 39 color_properties.include?(key.to_s)
  264. end
  265. # Process and replace a color value, returning the color key
  266. 1 def process_and_replace_color(color_value)
  267. # Skip data binding expressions
  268. 8 return color_value if color_value.is_a?(String) && color_value.start_with?('@{')
  269. # Handle hex colors
  270. 7 if is_hex_color?(color_value)
  271. # Normalize hex color (uppercase, with #)
  272. 6 hex_color = normalize_hex_color(color_value)
  273. # Check if color already exists in colors.json
  274. 6 existing_key = find_color_key(hex_color)
  275. 6 if existing_key
  276. # Color already exists, return the key
  277. 1 Core::Logger.debug "Found existing color: #{existing_key} = #{hex_color}"
  278. 1 return existing_key
  279. else
  280. # Generate a new key for this color
  281. 5 new_key = generate_color_key(hex_color)
  282. # Add to extracted colors
  283. 5 @extracted_colors[new_key] = hex_color
  284. 5 Core::Logger.debug "New color found: #{new_key} = #{hex_color}"
  285. 5 return new_key
  286. end
  287. # Handle string color keys
  288. 1 elsif color_value.is_a?(String) && !color_value.empty?
  289. # Check if this color key exists in colors.json
  290. 1 if @colors_data.key?(color_value) || @extracted_colors.key?(color_value)
  291. # Color key exists, keep it as is
  292. Core::Logger.debug "Color key exists: #{color_value}"
  293. return color_value
  294. 1 elsif @defined_colors_data.key?(color_value)
  295. # Already in defined_colors, keep it as is
  296. Core::Logger.debug "Color key already in defined_colors: #{color_value}"
  297. return color_value
  298. else
  299. # Undefined color key, add to undefined colors list
  300. 1 @undefined_colors[color_value] = nil
  301. 1 Core::Logger.debug "Undefined color key found: #{color_value}"
  302. 1 return color_value
  303. end
  304. else
  305. # Return as is for other types
  306. return color_value
  307. end
  308. end
  309. # Find existing key for a hex color
  310. 1 def find_color_key(hex_color)
  311. # Check both existing colors and newly extracted colors
  312. 6 all_colors = @colors_data.merge(@extracted_colors)
  313. 7 all_colors.find { |key, value| value.upcase == hex_color.upcase }&.first
  314. end
  315. # Generate a descriptive key name based on RGB values
  316. 1 def generate_color_key(hex_color)
  317. # Parse RGB values from hex
  318. 11 rgb = parse_hex_to_rgb(hex_color)
  319. 11 return 'unknown_color' unless rgb
  320. 11 r, g, b = rgb
  321. # Calculate brightness and dominant color
  322. 11 brightness = (r + g + b) / 3.0
  323. # Determine base name from brightness
  324. 11 base_name = if brightness > 230
  325. 1 'white'
  326. 10 elsif brightness > 200
  327. 'pale'
  328. 10 elsif brightness > 150
  329. 'light'
  330. 10 elsif brightness > 100
  331. 1 'medium'
  332. 9 elsif brightness > 50
  333. 6 'dark'
  334. 3 elsif brightness > 20
  335. 'deep'
  336. else
  337. 3 'black'
  338. end
  339. # Find dominant color if not grayscale
  340. 11 max_diff = [r, g, b].max - [r, g, b].min
  341. 11 if max_diff > 30 # Not grayscale
  342. # Determine dominant color
  343. 6 if r > g && r > b
  344. 6 if r - g > 50 && r - b > 50
  345. 6 color_suffix = '_red'
  346. elsif r > b
  347. color_suffix = '_orange' if g > b
  348. color_suffix = '_pink' if b > g * 0.7
  349. else
  350. color_suffix = '_magenta'
  351. end
  352. elsif g > r && g > b
  353. if g - r > 50 && g - b > 50
  354. color_suffix = '_green'
  355. elsif g > b && r > b * 0.7
  356. color_suffix = '_yellow'
  357. else
  358. color_suffix = '_lime'
  359. end
  360. elsif b > r && b > g
  361. if b - r > 50 && b - g > 50
  362. color_suffix = '_blue'
  363. elsif b > r && g > r * 0.7
  364. color_suffix = '_cyan'
  365. else
  366. color_suffix = '_purple'
  367. end
  368. else
  369. color_suffix = ''
  370. end
  371. 6 base_name = base_name + color_suffix unless base_name == 'white' || base_name == 'black'
  372. 5 elsif base_name != 'white' && base_name != 'black'
  373. 1 base_name = base_name + '_gray'
  374. end
  375. # Handle duplicates by adding suffix
  376. 11 final_key = base_name
  377. 11 counter = 2
  378. 11 all_colors = @colors_data.merge(@extracted_colors)
  379. 11 while all_colors.key?(final_key)
  380. 1 final_key = "#{base_name}_#{counter}"
  381. 1 counter += 1
  382. end
  383. 11 final_key
  384. end
  385. # Parse hex color to RGB values (and alpha if present)
  386. 1 def parse_hex_to_rgb(hex_color)
  387. # Remove # if present
  388. 17 hex = hex_color.gsub('#', '')
  389. # Support both 3 and 6 digit hex
  390. 17 if hex.length == 3
  391. 4 hex = hex.chars.map { |c| c * 2 }.join
  392. end
  393. # Handle 8-digit hex (ARGB) - extract RGB part
  394. 17 if hex.length == 8
  395. # Skip alpha channel (first 2 characters) for RGB analysis
  396. 1 hex = hex[2..7]
  397. end
  398. 17 return nil unless hex.length == 6
  399. [
  400. 16 hex[0..1].to_i(16),
  401. hex[2..3].to_i(16),
  402. hex[4..5].to_i(16)
  403. ]
  404. rescue
  405. nil
  406. end
  407. # Check if a value is a hex color
  408. 1 def is_hex_color?(value)
  409. 16 return false unless value.is_a?(String)
  410. # Support 3, 6, and 8 character hex colors (8 = ARGB with alpha)
  411. 14 value.match?(/^#?[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?([0-9A-Fa-f]{2})?$/)
  412. end
  413. # Normalize hex color format
  414. 1 def normalize_hex_color(hex_color)
  415. 10 hex = hex_color.gsub('#', '').upcase
  416. # Convert 3-digit to 6-digit
  417. 10 if hex.length == 3
  418. 4 hex = hex.chars.map { |c| c * 2 }.join
  419. end
  420. # Keep 8-digit (ARGB) as is
  421. # 6-digit and 8-digit are both valid
  422. 10 "##{hex}"
  423. end
  424. # Generate Kotlin code for ColorManager
  425. 1 def generate_color_manager_kotlin
  426. return unless @config['resource_manager_directory']
  427. resource_manager_dir = File.join(@source_path, @config['source_directory'] || 'src/main',
  428. 'java/com/kotlinjsonui/generated')
  429. FileUtils.mkdir_p(resource_manager_dir)
  430. output_file = File.join(resource_manager_dir, 'ColorManager.kt')
  431. # Combine all colors (from colors.json and defined_colors.json)
  432. all_colors = @colors_data.dup
  433. # Add defined colors (keys without values yet)
  434. @defined_colors_data.each do |key, _|
  435. all_colors[key] ||= nil
  436. end
  437. kotlin_code = generate_kotlin_code(all_colors)
  438. File.write(output_file, kotlin_code)
  439. Core::Logger.info "✓ Generated ColorManager.kt"
  440. end
  441. 1 def generate_kotlin_code(colors)
  442. 2 timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
  443. 2 code = []
  444. 2 code << "// ColorManager.kt"
  445. 2 code << "// Auto-generated file - DO NOT EDIT"
  446. 2 code << "// Generated at: #{timestamp}"
  447. 2 code << ""
  448. 2 code << "package com.kotlinjsonui.generated"
  449. 2 code << ""
  450. 2 code << "import android.graphics.Color"
  451. 2 code << "import androidx.compose.ui.graphics.Color as ComposeColor"
  452. 2 code << ""
  453. 2 code << "object ColorManager {"
  454. 2 code << " // Load colors from colors.json"
  455. 2 code << " private val colorsData: Map<String, String> = mapOf("
  456. # Add defined colors from colors.json
  457. 2 @colors_data.each do |key, hex_value|
  458. code << " \"#{key}\" to \"#{hex_value}\","
  459. end
  460. # Remove trailing comma from last item
  461. 2 if @colors_data.any?
  462. code[-1] = code[-1].chomp(',')
  463. end
  464. 2 code << " )"
  465. 2 code << ""
  466. 2 code << " // Android View colors"
  467. 2 code << " object android {"
  468. 2 code << " // Get Color by key"
  469. 2 code << " fun color(key: String): Int {"
  470. 2 code << " val hexString = colorsData[key]"
  471. 2 code << " if (hexString == null) {"
  472. 2 code << " println(\"Warning: Color key '$key' not found in colors.json\")"
  473. 2 code << " return Color.GRAY // Default fallback color"
  474. 2 code << " }"
  475. 2 code << " return try {"
  476. 2 code << " Color.parseColor(hexString)"
  477. 2 code << " } catch (e: IllegalArgumentException) {"
  478. 2 code << " println(\"Warning: Invalid color format '$hexString' for key '$key'\")"
  479. 2 code << " Color.GRAY"
  480. 2 code << " }"
  481. 2 code << " }"
  482. 2 code << ""
  483. # Generate static color accessors for Android
  484. 2 colors.keys.sort.each do |key|
  485. 2 property_name = snake_to_camel(key)
  486. 2 code << " val #{property_name}: Int"
  487. 2 code << " get() {"
  488. 2 if @colors_data[key]
  489. code << " return try {"
  490. code << " Color.parseColor(\"#{@colors_data[key]}\")"
  491. code << " } catch (e: IllegalArgumentException) {"
  492. code << " Color.GRAY"
  493. code << " }"
  494. else
  495. 2 code << " // Undefined color - needs to be defined in colors.json"
  496. 2 code << " println(\"Warning: Color '#{key}' is not defined in colors.json\")"
  497. 2 code << " return Color.GRAY // Fallback color"
  498. end
  499. 2 code << " }"
  500. 2 code << ""
  501. end
  502. 2 code << " }"
  503. 2 code << ""
  504. 2 code << " // Compose colors"
  505. 2 code << " object compose {"
  506. 2 code << " // Get Compose Color by key"
  507. 2 code << " fun color(key: String): ComposeColor {"
  508. 2 code << " val androidColor = android.color(key)"
  509. 2 code << " return ComposeColor(androidColor)"
  510. 2 code << " }"
  511. 2 code << ""
  512. # Generate static Compose Color accessors
  513. 2 colors.keys.sort.each do |key|
  514. 2 property_name = snake_to_camel(key)
  515. 2 code << " val #{property_name}: ComposeColor"
  516. 2 code << " get() = ComposeColor(android.#{property_name})"
  517. 2 code << ""
  518. end
  519. 2 code << " }"
  520. 2 code << "}"
  521. 2 code.join("\n")
  522. end
  523. 1 def snake_to_camel(snake_case)
  524. # Convert snake_case to camelCase
  525. # Examples:
  526. # primary_blue -> primaryBlue
  527. # white_2 -> white2
  528. # dark_gray -> darkGray
  529. 8 parts = snake_case.split('_')
  530. 8 first_part = parts.shift
  531. 8 camel = first_part + parts.map(&:capitalize).join
  532. 8 camel
  533. end
  534. end
  535. end
  536. end
  537. end

lib/core/resources/string_manager.rb

61.6% lines covered

237 relevant lines. 146 lines covered and 91 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require 'rexml/document'
  5. 1 require_relative '../logger'
  6. 1 module KjuiTools
  7. 1 module Core
  8. 1 module Resources
  9. 1 class StringManager
  10. 1 def initialize(config, source_path, resources_dir)
  11. 47 @config = config
  12. 47 @source_path = source_path
  13. 47 @resources_dir = resources_dir
  14. 47 @strings_file = File.join(@resources_dir, 'strings.json')
  15. 47 @extracted_strings = {} # Structure: { "filename": { "key": "value" } }
  16. 47 @strings_data = load_strings_json
  17. end
  18. # Main process method called from ResourcesManager
  19. 1 def process_strings(processed_files, processed_count, skipped_count)
  20. 8 return if processed_files.empty?
  21. 7 Core::Logger.info "Extracting strings from #{processed_count} files (#{skipped_count} skipped)..."
  22. # Extract strings from JSON files
  23. 7 extract_strings(processed_files)
  24. # Save updated strings.json if there are new strings
  25. 7 save_strings_json if @extracted_strings.any?
  26. # Generate StringManager.kt if needed
  27. # Disabled: StringManager.kt generation is not needed
  28. # generate_string_manager_kotlin if @config['resource_manager_directory']
  29. end
  30. # Apply extracted strings to strings.xml files
  31. 1 def apply_to_strings_files
  32. 8 return if @strings_data.empty?
  33. # Get string files from config
  34. 4 string_files = @config['string_files'] || []
  35. 4 if string_files.empty?
  36. # Default: update strings.xml for default language
  37. 4 update_strings_xml('values')
  38. else
  39. # Update configured string files
  40. string_files.each do |string_file_path|
  41. # Extract values directory from path (e.g., "res/values-ja/strings.xml" -> "values-ja")
  42. if string_file_path =~ /res\/(values[^\/]*)\//
  43. lang_dir = $1
  44. update_strings_xml(lang_dir)
  45. elsif string_file_path =~ /(values[^\/]*)\//
  46. lang_dir = $1
  47. update_strings_xml(lang_dir)
  48. else
  49. # If no standard pattern, try to use the parent directory name
  50. parts = string_file_path.split('/')
  51. if parts.length >= 2
  52. lang_dir = parts[-2]
  53. update_strings_xml(lang_dir) if lang_dir.start_with?('values')
  54. end
  55. end
  56. end
  57. end
  58. end
  59. 1 private
  60. # Load existing strings.json file
  61. 1 def load_strings_json
  62. 47 return {} unless File.exist?(@strings_file)
  63. begin
  64. JSON.parse(File.read(@strings_file))
  65. rescue JSON::ParserError => e
  66. Core::Logger.warn "Failed to parse strings.json: #{e.message}"
  67. {}
  68. end
  69. end
  70. # Save strings data to strings.json
  71. 1 def save_strings_json
  72. # Count total new strings
  73. 4 total_new_strings = 0
  74. 4 @extracted_strings.each do |file_prefix, file_strings|
  75. 4 total_new_strings += file_strings.size
  76. end
  77. # Merge extracted strings with existing strings
  78. 4 @extracted_strings.each do |file_prefix, file_strings|
  79. 4 @strings_data[file_prefix] ||= {}
  80. 4 @strings_data[file_prefix].merge!(file_strings)
  81. end
  82. # Ensure Resources directory exists
  83. 4 FileUtils.mkdir_p(@resources_dir)
  84. # Write strings.json
  85. 4 File.write(@strings_file, JSON.pretty_generate(@strings_data))
  86. 4 Core::Logger.info "Updated strings.json with #{total_new_strings} new strings"
  87. # Clear extracted strings after saving
  88. 4 @extracted_strings.clear
  89. end
  90. # Extract string values from processed JSON files
  91. 1 def extract_strings(processed_files)
  92. 7 @modified_files = []
  93. 7 Core::Logger.debug "Processing #{processed_files.size} files for strings"
  94. # Get the layouts directory to calculate relative paths
  95. 7 layouts_dir = File.join(@source_path, @config['source_directory'] || 'src/main', 'assets/Layouts')
  96. 7 processed_files.each do |json_file|
  97. begin
  98. 7 Core::Logger.debug "Processing file: #{json_file}"
  99. 7 content = File.read(json_file)
  100. 7 data = JSON.parse(content)
  101. # Get file prefix from relative path
  102. 7 relative_path = Pathname.new(json_file).relative_path_from(Pathname.new(layouts_dir)).to_s
  103. 7 file_prefix = generate_file_prefix(relative_path)
  104. # Create current file strings container if not exists
  105. 7 @current_file_strings = {}
  106. # Extract strings recursively from JSON structure (without modifying)
  107. 7 extract_strings_recursive(data, nil, file_prefix)
  108. # Store extracted strings for this file if any
  109. 7 if @current_file_strings.any?
  110. 4 @extracted_strings[file_prefix] ||= {}
  111. 4 @extracted_strings[file_prefix].merge!(@current_file_strings)
  112. 4 Core::Logger.debug "Extracted #{@current_file_strings.size} strings from #{file_prefix}"
  113. end
  114. # NOTE: We don't modify the original JSON files anymore
  115. # The resource resolution happens during code generation
  116. rescue JSON::ParserError => e
  117. Core::Logger.warn "Failed to parse #{json_file}: #{e.message}"
  118. rescue => e
  119. Core::Logger.error "Error processing #{json_file}: #{e.message}"
  120. end
  121. end
  122. 7 if @modified_files.any?
  123. Core::Logger.info "Replaced strings in #{@modified_files.size} files"
  124. end
  125. end
  126. # Generate file prefix from relative path
  127. 1 def generate_file_prefix(relative_path)
  128. # Remove .json extension and replace / with _
  129. # Examples:
  130. # "test.json" -> "test"
  131. # "subdir/test.json" -> "subdir_test"
  132. # "a/b/c/test.json" -> "a_b_c_test"
  133. 10 relative_path
  134. .gsub(/\.json$/, '')
  135. .gsub('/', '_')
  136. end
  137. # Extract strings recursively from JSON data (without modifying)
  138. 1 def extract_strings_recursive(data, parent_key = nil, file_prefix = nil)
  139. 14 case data
  140. when Hash
  141. 10 data.each do |key, value|
  142. # Special handling for partialAttributes
  143. 20 if key == 'partialAttributes' && value.is_a?(Array)
  144. value.each do |partial_attr|
  145. if partial_attr.is_a?(Hash) && partial_attr['range'].is_a?(String)
  146. # Process range text when it's a string (not an array)
  147. range_text = partial_attr['range']
  148. if !range_text.empty? && should_extract_string?(range_text)
  149. extract_and_store_string(range_text, file_prefix)
  150. end
  151. end
  152. end
  153. # Regular string property handling
  154. 20 elsif is_string_property?(key) && value.is_a?(String) && !value.empty?
  155. # Extract the string value
  156. 4 if should_extract_string?(value)
  157. 4 extract_and_store_string(value, file_prefix)
  158. end
  159. 16 elsif value.is_a?(Hash) || value.is_a?(Array)
  160. # Recurse into nested structures
  161. 4 extract_strings_recursive(value, key, file_prefix)
  162. end
  163. end
  164. when Array
  165. 4 data.each_with_index do |item, index|
  166. 3 if item.is_a?(Hash) || item.is_a?(Array)
  167. 3 extract_strings_recursive(item, parent_key, file_prefix)
  168. end
  169. end
  170. end
  171. end
  172. # Check if a property name is likely to contain a localizable string
  173. 1 def is_string_property?(key)
  174. # Based on actual XML mapper and Compose components code
  175. 27 string_properties = [
  176. 'text', # Text, Button, TextField, TextView, Checkbox
  177. 'hint', # TextField, SelectBox (both XML and Compose)
  178. 'placeholder', # TextField, SelectBox alternative to hint
  179. 'label', # Checkbox label
  180. 'prompt' # SelectBox (maps to placeholder in XML)
  181. ]
  182. 27 string_properties.include?(key.to_s)
  183. end
  184. # Check if a string should be extracted for localization
  185. 1 def should_extract_string?(value)
  186. # Skip data binding expressions
  187. 11 return false if value.start_with?('@{') || value.start_with?('${')
  188. # Extract if it's snake_case (like SwiftJsonUI)
  189. 9 return true if value.match?(/^[a-z]+(_[a-z]+)*$/)
  190. # Extract if it's a regular text string longer than 2 characters
  191. # and contains alphabetic characters
  192. 7 value.length > 2 && value.match?(/[a-zA-Z]/)
  193. end
  194. # Extract and store string (without returning a key)
  195. 1 def extract_and_store_string(value, file_prefix = nil)
  196. # Generate a snake_case key from the text
  197. 4 key = generate_string_key(value)
  198. # Check if this exact string already has a key in this file
  199. 4 existing_key = find_string_key_in_file(value, file_prefix)
  200. 4 if existing_key
  201. Core::Logger.debug "String already extracted: #{existing_key}"
  202. return
  203. end
  204. # Add to current file strings
  205. 4 @current_file_strings[key] = value
  206. 4 Core::Logger.debug "New string extracted: #{key} = '#{value}'"
  207. end
  208. # Find existing key for a string value in a specific file
  209. 1 def find_string_key_in_file(value, file_prefix)
  210. 4 return nil unless file_prefix
  211. # Check if this file has been processed before
  212. 4 if @strings_data[file_prefix]
  213. # Look for existing key in this file's strings
  214. @strings_data[file_prefix].find { |key, val| val == value }&.first
  215. end
  216. # Also check current file's strings being extracted
  217. 4 if @current_file_strings
  218. 4 found_key = @current_file_strings.find { |key, val| val == value }&.first
  219. 4 return "#{file_prefix}_#{found_key}" if found_key
  220. end
  221. nil
  222. end
  223. # Find existing key for a string value (legacy method)
  224. 1 def find_string_key(value)
  225. # Check both existing strings and newly extracted strings
  226. all_strings = @strings_data.merge(@extracted_strings)
  227. all_strings.find { |key, val| val == value }&.first
  228. end
  229. # Generate a snake_case key from text
  230. 1 def generate_string_key(text)
  231. # Convert to snake_case
  232. 9 base_key = text
  233. .downcase
  234. .gsub(/[^a-z0-9\s]/, '') # Remove special characters
  235. .gsub(/\s+/, '_') # Replace spaces with underscores
  236. .gsub(/^_+|_+$/, '') # Remove leading/trailing underscores
  237. .gsub(/__+/, '_') # Replace multiple underscores with single
  238. # Limit length
  239. 9 base_key = base_key[0..30] if base_key.length > 30
  240. # Handle duplicates
  241. 9 final_key = base_key
  242. 9 counter = 2
  243. 9 all_strings = @strings_data.merge(@extracted_strings)
  244. 9 while all_strings.key?(final_key)
  245. final_key = "#{base_key}_#{counter}"
  246. counter += 1
  247. end
  248. 9 final_key
  249. end
  250. # Update strings.xml file for a specific language
  251. 1 def update_strings_xml(lang_dir)
  252. 3 Core::Logger.debug "Updating strings.xml for #{lang_dir}..."
  253. 3 res_dir = File.join(@source_path, @config['source_directory'] || 'src/main', 'res', lang_dir)
  254. 3 FileUtils.mkdir_p(res_dir)
  255. 3 strings_xml_file = File.join(res_dir, 'strings.xml')
  256. 3 Core::Logger.debug "Strings.xml path: #{strings_xml_file}"
  257. # Load existing strings.xml or create new
  258. 3 doc = if File.exist?(strings_xml_file)
  259. Core::Logger.debug "Loading existing strings.xml..."
  260. REXML::Document.new(File.read(strings_xml_file))
  261. else
  262. 3 Core::Logger.debug "Creating new strings.xml..."
  263. 3 create_new_strings_xml
  264. end
  265. 3 resources = doc.root
  266. 3 Core::Logger.debug "Processing #{@strings_data.keys.length} files..."
  267. # Build a hash of existing strings for faster lookup
  268. 3 existing_strings = {}
  269. 3 resources.elements.each('string') do |elem|
  270. name = elem.attributes['name']
  271. existing_strings[name] = elem if name
  272. end
  273. 3 Core::Logger.debug "Found #{existing_strings.keys.length} existing strings"
  274. # Add new strings from strings.json (now structured by file)
  275. 3 @strings_data.each do |file_prefix, file_strings|
  276. 3 next unless file_strings.is_a?(Hash)
  277. 3 Core::Logger.debug "Processing #{file_prefix} with #{file_strings.keys.length} strings..."
  278. 3 file_strings.each do |key, value|
  279. # Create full key with file prefix
  280. 3 full_key = "#{file_prefix}_#{key}"
  281. # Check if string already exists (using hash lookup - much faster)
  282. 3 unless existing_strings[full_key]
  283. # Add new string element
  284. 3 string_elem = REXML::Element.new('string')
  285. 3 string_elem.add_attribute('name', full_key)
  286. # Use translated value if available for this language
  287. 3 translated_value = get_translated_value(full_key, value, lang_dir)
  288. # Trim whitespace and normalize the string for XML
  289. 3 normalized_value = translated_value.strip.gsub(/\s+/, ' ')
  290. # Escape apostrophes for Android XML strings
  291. 3 normalized_value = normalized_value.gsub("'", "\\'")
  292. # Don't let REXML auto-escape, we'll do it manually
  293. 3 string_elem.text = normalized_value
  294. 3 resources.add_element(string_elem)
  295. 3 Core::Logger.debug "Added string '#{full_key}' to #{lang_dir}/strings.xml"
  296. end
  297. end
  298. end
  299. # Write updated XML with custom formatting to prevent multiline strings
  300. 3 File.open(strings_xml_file, 'w') do |file|
  301. # Use a custom formatter that doesn't wrap text content
  302. 3 formatter = REXML::Formatters::Pretty.new(4)
  303. 3 formatter.compact = true # Don't add extra whitespace inside text
  304. 3 formatter.write(doc, file)
  305. end
  306. 3 Core::Logger.info "Updated #{lang_dir}/strings.xml"
  307. end
  308. # Create a new strings.xml document
  309. 1 def create_new_strings_xml
  310. 5 doc = REXML::Document.new
  311. 5 doc.add(REXML::XMLDecl.new('1.0', 'utf-8'))
  312. 5 resources = REXML::Element.new('resources')
  313. 5 doc.add_element(resources)
  314. 5 doc
  315. end
  316. # Get translated value for a specific language
  317. 1 def get_translated_value(key, default_value, lang_dir)
  318. # For now, return the default value
  319. # In the future, this could load translations from a separate file
  320. 4 default_value
  321. end
  322. # Generate Kotlin code for StringManager
  323. 1 def generate_string_manager_kotlin
  324. return unless @config['resource_manager_directory']
  325. resource_manager_dir = File.join(@source_path, @config['source_directory'] || 'src/main',
  326. 'java/com/kotlinjsonui/generated')
  327. FileUtils.mkdir_p(resource_manager_dir)
  328. output_file = File.join(resource_manager_dir, 'StringManager.kt')
  329. kotlin_code = generate_kotlin_code(@strings_data)
  330. File.write(output_file, kotlin_code)
  331. Core::Logger.info "✓ Generated StringManager.kt"
  332. end
  333. 1 def generate_kotlin_code(strings)
  334. timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
  335. code = []
  336. code << "// StringManager.kt"
  337. code << "// Auto-generated file - DO NOT EDIT"
  338. code << "// Generated at: #{timestamp}"
  339. code << ""
  340. code << "package com.kotlinjsonui.generated"
  341. code << ""
  342. code << "import android.content.Context"
  343. code << ""
  344. code << "object StringManager {"
  345. code << " // String resource IDs mapped from strings.json keys"
  346. code << " private val stringResources: Map<String, Int> = mapOf("
  347. # Add string resource mappings
  348. strings.keys.sort.each do |key|
  349. code << " \"#{key}\" to R.string.#{key},"
  350. end
  351. # Remove trailing comma from last item
  352. if strings.any?
  353. code[-1] = code[-1].chomp(',')
  354. end
  355. code << " )"
  356. code << ""
  357. code << " // Get localized string by key"
  358. code << " fun getString(context: Context, key: String): String {"
  359. code << " val resId = stringResources[key]"
  360. code << " return if (resId != null) {"
  361. code << " context.getString(resId)"
  362. code << " } else {"
  363. code << " // Fallback to key itself if not found"
  364. code << " println(\"Warning: String key '$key' not found in strings.json\")"
  365. code << " key"
  366. code << " }"
  367. code << " }"
  368. code << ""
  369. code << " // Extension function for easy access"
  370. code << " fun String.localized(context: Context): String {"
  371. code << " // Check if this is a string key (snake_case)"
  372. code << " return if (this.matches(Regex(\"^[a-z]+(_[a-z]+)*$\"))) {"
  373. code << " getString(context, this)"
  374. code << " } else {"
  375. code << " // Return as-is if not a key"
  376. code << " this"
  377. code << " }"
  378. code << " }"
  379. code << ""
  380. # Generate static accessors for each string
  381. strings.keys.sort.each do |key|
  382. property_name = snake_to_camel(key)
  383. code << " // Access string: #{key}"
  384. code << " fun get#{property_name.capitalize}(context: Context): String ="
  385. code << " getString(context, \"#{key}\")"
  386. code << ""
  387. end
  388. code << "}"
  389. code.join("\n")
  390. end
  391. 1 def snake_to_camel(snake_case)
  392. 3 parts = snake_case.split('_')
  393. 3 first_part = parts.shift
  394. 3 camel = first_part + parts.map(&:capitalize).join
  395. 3 camel
  396. end
  397. end
  398. end
  399. end
  400. end

lib/core/resources_manager.rb

100.0% lines covered

45 relevant lines. 45 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require_relative 'config_manager'
  5. 1 require_relative 'project_finder'
  6. 1 require_relative 'logger'
  7. 1 require_relative 'resources/string_manager'
  8. 1 require_relative 'resources/color_manager'
  9. 1 module KjuiTools
  10. 1 module Core
  11. 1 class ResourcesManager
  12. 1 def initialize(config, source_path)
  13. 16 @config = config
  14. 16 @source_path = source_path
  15. 16 @layouts_dir = File.join(@source_path, @config['source_directory'] || 'src/main', 'assets/Layouts')
  16. 16 @resources_dir = File.join(@layouts_dir, 'Resources')
  17. 16 @string_manager = Resources::StringManager.new(@config, @source_path, @resources_dir)
  18. 16 @color_manager = Resources::ColorManager.new(@config, @source_path, @resources_dir)
  19. end
  20. # Main method called from build command
  21. 1 def extract_resources(json_files)
  22. # Extract resources from JSON files
  23. 9 extract_from_json_files(json_files)
  24. # Apply extracted strings to strings.xml files
  25. 9 apply_extracted_strings
  26. # Apply extracted colors
  27. 9 apply_extracted_colors
  28. end
  29. # Extract resources from JSON files
  30. 1 def extract_from_json_files(json_files)
  31. 12 processed_files = []
  32. 12 processed_count = 0
  33. 12 skipped_count = 0
  34. 12 json_files.each do |json_file|
  35. # Skip files in Resources directory only
  36. 10 if json_file.include?('/Resources/')
  37. 2 skipped_count += 1
  38. 2 next
  39. end
  40. 8 processed_files << json_file
  41. 8 processed_count += 1
  42. end
  43. 12 if processed_count == 0
  44. 4 Logger.info "No files to process for resource extraction"
  45. 4 return
  46. end
  47. 8 Logger.info "Extracting resources from #{processed_count} files (#{skipped_count} skipped)..."
  48. # Ensure Resources directory exists
  49. 8 FileUtils.mkdir_p(@resources_dir)
  50. # Process strings through StringManager
  51. 8 @string_manager.process_strings(processed_files, processed_count, skipped_count)
  52. # Process colors through ColorManager
  53. 8 @color_manager.process_colors(processed_files, processed_count, skipped_count)
  54. end
  55. 1 private
  56. 1 def apply_extracted_strings
  57. 9 Logger.info "Applying extracted strings to strings.xml files..."
  58. 9 @string_manager.apply_to_strings_files
  59. end
  60. 1 def apply_extracted_colors
  61. 9 Logger.info "Applying extracted colors..."
  62. 9 @color_manager.apply_to_color_assets
  63. end
  64. end
  65. end
  66. end

lib/core/style_loader.rb

100.0% lines covered

35 relevant lines. 35 lines covered and 0 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 class StyleLoader
  4. 1 def initialize(config)
  5. 39 @config = config
  6. 39 @styles = {}
  7. 39 load_styles
  8. end
  9. 1 def apply_styles(json_data)
  10. 13 apply_styles_recursive(json_data)
  11. 13 json_data
  12. end
  13. 1 private
  14. 1 def load_styles
  15. 39 project_path = @config['project_path'] || Dir.pwd
  16. 39 styles_dir = File.join(project_path, 'src', 'main', 'assets', 'Styles')
  17. 39 styles_dir = File.join(project_path, 'app', 'src', 'main', 'assets', 'Styles') unless Dir.exist?(styles_dir)
  18. 39 return unless Dir.exist?(styles_dir)
  19. 14 Dir.glob(File.join(styles_dir, '*.json')).each do |style_file|
  20. 25 style_name = File.basename(style_file, '.json')
  21. begin
  22. 25 style_content = File.read(style_file)
  23. 25 @styles[style_name] = JSON.parse(style_content)
  24. rescue => e
  25. 1 puts "Warning: Failed to load style #{style_name}: #{e.message}"
  26. end
  27. end
  28. end
  29. 1 def apply_styles_recursive(element)
  30. 18 return unless element.is_a?(Hash)
  31. # Apply style if present
  32. 17 if element['style']
  33. 8 style_names = element['style'].is_a?(Array) ? element['style'] : [element['style']]
  34. 8 style_names.each do |style_name|
  35. 9 if @styles[style_name]
  36. # Merge style attributes (style attributes are overridden by inline attributes)
  37. 8 @styles[style_name].each do |key, value|
  38. 15 element[key] = value unless element.key?(key)
  39. end
  40. end
  41. end
  42. # Remove style attribute after applying
  43. 8 element.delete('style')
  44. end
  45. # Apply recursively to children
  46. 17 if element['children']
  47. 2 element['children'].each { |child| apply_styles_recursive(child) }
  48. 16 elsif element['child']
  49. 5 if element['child'].is_a?(Array)
  50. 7 element['child'].each { |child| apply_styles_recursive(child) }
  51. else
  52. 1 apply_styles_recursive(element['child'])
  53. end
  54. end
  55. end
  56. end

lib/hotloader/ip_monitor.rb

94.25% lines covered

87 relevant lines. 82 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'socket'
  3. 1 require 'json'
  4. 1 require 'fileutils'
  5. 1 module KjuiTools
  6. 1 module Hotloader
  7. 1 class IpMonitor
  8. 1 CONFIG_FILE = 'kjui.config.json'
  9. 1 CHECK_INTERVAL = 5 # seconds
  10. 1 def initialize(project_root = nil)
  11. 14 @project_root = project_root || find_project_root
  12. 14 @config_path = File.join(@project_root, CONFIG_FILE)
  13. 14 @running = false
  14. 14 @thread = nil
  15. 14 @last_ip = nil
  16. end
  17. 1 def start
  18. 4 return if @running
  19. 3 @running = true
  20. 3 @thread = Thread.new do
  21. 3 while @running
  22. 1 check_and_update_ip
  23. 1 sleep CHECK_INTERVAL
  24. end
  25. end
  26. 3 puts "IP Monitor started"
  27. end
  28. 1 def stop
  29. 3 @running = false
  30. 3 @thread&.join
  31. 3 puts "IP Monitor stopped"
  32. end
  33. 1 private
  34. 1 def find_project_root(start_path = Dir.pwd)
  35. 3 current = start_path
  36. # First check current and parent directories
  37. 3 while current != '/'
  38. 8 if File.exist?(File.join(current, CONFIG_FILE))
  39. 1 return current
  40. end
  41. # Check subdirectories for kjui.config.json
  42. 7 Dir.glob(File.join(current, '*', CONFIG_FILE)).each do |config_path|
  43. 1 if File.exist?(config_path)
  44. 1 return File.dirname(config_path)
  45. end
  46. end
  47. 6 current = File.dirname(current)
  48. end
  49. 1 Dir.pwd
  50. end
  51. 1 def check_and_update_ip
  52. 1 current_ip = get_local_ip
  53. 1 if current_ip && current_ip != @last_ip
  54. 1 update_config(current_ip)
  55. 1 update_android_configs(current_ip)
  56. 1 @last_ip = current_ip
  57. 1 puts "IP updated to: #{current_ip}"
  58. end
  59. rescue => e
  60. puts "Error checking IP: #{e.message}"
  61. end
  62. 1 def get_local_ip
  63. # Try to get WiFi IP first (common interface names)
  64. 2 interfaces = ['wlan0', 'wlp2s0', 'wlp3s0', 'en0', 'en1', 'eth0', 'eth1']
  65. 2 interfaces.each do |interface|
  66. 8 ip = get_interface_ip(interface)
  67. 8 return ip if ip && !ip.start_with?('127.')
  68. end
  69. # Fallback: get any non-localhost IP
  70. Socket.ip_address_list.each do |addr|
  71. if addr.ipv4? && !addr.ipv4_loopback? && !addr.ipv4_multicast?
  72. return addr.ip_address
  73. end
  74. end
  75. nil
  76. end
  77. 1 def get_interface_ip(interface)
  78. 8 Socket.getifaddrs.each do |ifaddr|
  79. 266 if ifaddr.name == interface && ifaddr.addr&.ipv4?
  80. 2 return ifaddr.addr.ip_address
  81. end
  82. end
  83. nil
  84. rescue
  85. nil
  86. end
  87. 1 def update_config(ip)
  88. 3 config = if File.exist?(@config_path)
  89. 1 JSON.parse(File.read(@config_path))
  90. else
  91. 2 {}
  92. end
  93. 3 config['hotloader'] ||= {}
  94. 3 config['hotloader']['ip'] = ip
  95. 3 config['hotloader']['port'] ||= 8081
  96. 3 config['hotloader']['enabled'] = true
  97. 3 File.write(@config_path, JSON.pretty_generate(config))
  98. end
  99. 1 def update_android_configs(ip)
  100. # Update local.properties if it exists
  101. 2 local_props = File.join(@project_root, 'local.properties')
  102. 2 if File.exist?(local_props)
  103. 1 content = File.read(local_props)
  104. # Remove old hotloader.ip line if exists
  105. 1 content.gsub!(/^hotloader\.ip=.*$/, '')
  106. 1 content.gsub!(/^hotloader\.port=.*$/, '')
  107. # Add new lines
  108. 1 content += "\nhotloader.ip=#{ip}"
  109. 1 content += "\nhotloader.port=8081"
  110. 1 File.write(local_props, content)
  111. end
  112. # Update any BuildConfig or resource files
  113. 2 update_build_config(ip)
  114. end
  115. 1 def update_build_config(ip)
  116. # Load config to get source directory
  117. 3 config = if File.exist?(@config_path)
  118. 2 JSON.parse(File.read(@config_path))
  119. else
  120. 1 {}
  121. end
  122. 3 source_dir = config['source_directory'] || 'src/main'
  123. # Create or update hotloader config in assets
  124. 3 assets_dir = File.join(@project_root, source_dir, 'assets')
  125. 3 FileUtils.mkdir_p(assets_dir)
  126. 3 hotloader_config = File.join(assets_dir, 'hotloader.json')
  127. config = {
  128. 3 'ip' => ip,
  129. 'port' => 8081,
  130. 'enabled' => true,
  131. 'websocket_endpoint' => "ws://#{ip}:8081",
  132. 'http_endpoint' => "http://#{ip}:8081"
  133. }
  134. 3 File.write(hotloader_config, JSON.pretty_generate(config))
  135. end
  136. end
  137. end
  138. end

lib/xml/drawable/drawable_generator.rb

92.47% lines covered

93 relevant lines. 86 lines covered and 7 lines missed.
    
  1. 1 require 'digest'
  2. 1 require 'fileutils'
  3. 1 require_relative 'shape_drawable_generator'
  4. 1 require_relative 'ripple_drawable_generator'
  5. 1 require_relative 'state_list_drawable_generator'
  6. 1 require_relative 'drawable_hash_manager'
  7. 1 module DrawableGenerator
  8. 1 class Generator
  9. 1 def initialize(project_root)
  10. 59 @project_root = project_root
  11. # Check if we're already in a sample-app directory or need to look for one
  12. 59 if File.exist?(File.join(project_root, 'src', 'main', 'res'))
  13. 38 @drawable_dir = File.join(project_root, 'src', 'main', 'res', 'drawable')
  14. 21 elsif File.exist?(File.join(project_root, 'sample-app', 'src', 'main', 'res'))
  15. @drawable_dir = File.join(project_root, 'sample-app', 'src', 'main', 'res', 'drawable')
  16. 21 elsif File.exist?(File.join(project_root, 'app', 'src', 'main', 'res'))
  17. @drawable_dir = File.join(project_root, 'app', 'src', 'main', 'res', 'drawable')
  18. else
  19. # Default fallback
  20. 21 @drawable_dir = File.join(project_root, 'src', 'main', 'res', 'drawable')
  21. end
  22. 59 @hash_manager = DrawableHashManager.new(@drawable_dir)
  23. 59 @shape_generator = ShapeDrawableGenerator.new
  24. 59 @ripple_generator = RippleDrawableGenerator.new
  25. 59 @state_list_generator = StateListDrawableGenerator.new
  26. 59 ensure_drawable_directory
  27. end
  28. 1 def generate_for_component(json_data, component_type)
  29. 6 drawables = []
  30. # Check if we need a ripple effect drawable
  31. 6 if needs_ripple?(json_data, component_type)
  32. 4 drawable_name = generate_ripple_drawable(json_data, component_type)
  33. 4 drawables << drawable_name if drawable_name
  34. end
  35. # Check if we need a shape drawable
  36. 6 if needs_shape?(json_data)
  37. 3 drawable_name = generate_shape_drawable(json_data, component_type)
  38. 3 drawables << drawable_name if drawable_name
  39. end
  40. # Check if we need a state list drawable
  41. 6 if needs_state_list?(json_data)
  42. drawable_name = generate_state_list_drawable(json_data, component_type)
  43. drawables << drawable_name if drawable_name
  44. end
  45. 6 drawables.first # Return the primary drawable (usually state list or ripple)
  46. end
  47. 1 def get_background_drawable(json_data, component_type)
  48. 5 return nil unless json_data
  49. # Priority order: state list > ripple > shape > color
  50. 4 if needs_state_list?(json_data)
  51. 1 generate_state_list_drawable(json_data, component_type)
  52. 3 elsif needs_ripple?(json_data, component_type)
  53. 1 generate_ripple_drawable(json_data, component_type)
  54. 2 elsif needs_shape?(json_data)
  55. 1 generate_shape_drawable(json_data, component_type)
  56. else
  57. nil
  58. end
  59. end
  60. 1 private
  61. 1 def ensure_drawable_directory
  62. 59 FileUtils.mkdir_p(@drawable_dir) unless Dir.exist?(@drawable_dir)
  63. end
  64. 1 def needs_ripple?(json_data, component_type)
  65. 17 return false unless json_data
  66. # Check for click handlers
  67. 15 has_click_handler = json_data['onClick'] || json_data['onclick']
  68. # Certain component types should have ripple by default
  69. 15 clickable_components = ['Button', 'ImageButton', 'Card', 'ListItem']
  70. 15 is_clickable_component = clickable_components.include?(component_type)
  71. 15 has_click_handler || is_clickable_component
  72. end
  73. 1 def needs_shape?(json_data)
  74. 15 return false unless json_data
  75. # Check for shape-related attributes
  76. 13 json_data['cornerRadius'] ||
  77. json_data['borderWidth'] ||
  78. json_data['borderColor'] ||
  79. json_data['background']&.start_with?('#') ||
  80. json_data['gradient']
  81. end
  82. 1 def needs_state_list?(json_data)
  83. 17 return false unless json_data
  84. # Check for state-specific attributes
  85. 15 json_data['disabledBackground'] ||
  86. json_data['tapBackground'] ||
  87. json_data['selectedBackground'] ||
  88. json_data['pressedBackground'] ||
  89. json_data['focusedBackground']
  90. end
  91. 1 def generate_ripple_drawable(json_data, component_type)
  92. # Generate content based on attributes
  93. 5 drawable_content = @ripple_generator.generate(json_data, component_type)
  94. 5 return nil unless drawable_content
  95. # Generate hash-based filename
  96. 5 drawable_hash = @hash_manager.generate_hash(drawable_content)
  97. 5 drawable_name = "ripple_#{drawable_hash}"
  98. # Check if drawable already exists
  99. 5 if @hash_manager.drawable_exists?(drawable_name)
  100. return drawable_name
  101. end
  102. # Write the drawable file
  103. 5 drawable_path = File.join(@drawable_dir, "#{drawable_name}.xml")
  104. 5 File.write(drawable_path, drawable_content)
  105. 5 @hash_manager.register_drawable(drawable_name, drawable_content)
  106. 5 drawable_name
  107. end
  108. 1 def generate_shape_drawable(json_data, component_type)
  109. # Generate content based on attributes
  110. 5 drawable_content = @shape_generator.generate(json_data)
  111. 5 return nil unless drawable_content
  112. # Generate hash-based filename
  113. 5 drawable_hash = @hash_manager.generate_hash(drawable_content)
  114. 5 drawable_name = "shape_#{drawable_hash}"
  115. # Check if drawable already exists
  116. 5 if @hash_manager.drawable_exists?(drawable_name)
  117. return drawable_name
  118. end
  119. # Write the drawable file
  120. 5 drawable_path = File.join(@drawable_dir, "#{drawable_name}.xml")
  121. 5 File.write(drawable_path, drawable_content)
  122. 5 @hash_manager.register_drawable(drawable_name, drawable_content)
  123. 5 drawable_name
  124. end
  125. 1 def generate_state_list_drawable(json_data, component_type)
  126. # Generate content based on attributes
  127. 1 drawable_content = @state_list_generator.generate(json_data, self)
  128. 1 return nil unless drawable_content
  129. # Generate hash-based filename
  130. 1 drawable_hash = @hash_manager.generate_hash(drawable_content)
  131. 1 drawable_name = "selector_#{drawable_hash}"
  132. # Check if drawable already exists
  133. 1 if @hash_manager.drawable_exists?(drawable_name)
  134. return drawable_name
  135. end
  136. # Write the drawable file
  137. 1 drawable_path = File.join(@drawable_dir, "#{drawable_name}.xml")
  138. 1 File.write(drawable_path, drawable_content)
  139. 1 @hash_manager.register_drawable(drawable_name, drawable_content)
  140. 1 drawable_name
  141. end
  142. # Public method for state list generator to create sub-drawables
  143. 1 def create_shape_drawable_for_state(state_data)
  144. 2 return nil unless state_data
  145. 1 generate_shape_drawable(state_data, nil)
  146. end
  147. end
  148. end

lib/xml/drawable/drawable_hash_manager.rb

47.76% lines covered

67 relevant lines. 32 lines covered and 35 lines missed.
    
  1. 1 require 'digest'
  2. 1 require 'json'
  3. 1 module DrawableGenerator
  4. 1 class DrawableHashManager
  5. 1 HASH_REGISTRY_FILE = '.drawable_hashes.json'
  6. 1 def initialize(drawable_dir)
  7. 59 @drawable_dir = drawable_dir
  8. 59 @registry_path = File.join(@drawable_dir, HASH_REGISTRY_FILE)
  9. 59 @registry = load_registry
  10. 59 @session_cache = {}
  11. end
  12. 1 def generate_hash(content)
  13. # Generate a short hash from the content
  14. 22 full_hash = Digest::SHA256.hexdigest(content)
  15. # Use first 8 characters for readability while maintaining uniqueness
  16. 22 full_hash[0..7]
  17. end
  18. 1 def drawable_exists?(drawable_name)
  19. # Check session cache first
  20. 11 return true if @session_cache[drawable_name]
  21. # Check file system
  22. 11 file_path = File.join(@drawable_dir, "#{drawable_name}.xml")
  23. 11 exists = File.exist?(file_path)
  24. # Update cache if exists
  25. 11 @session_cache[drawable_name] = true if exists
  26. 11 exists
  27. end
  28. 1 def register_drawable(drawable_name, content)
  29. # Add to session cache
  30. 11 @session_cache[drawable_name] = true
  31. # Add to registry with metadata
  32. 11 @registry[drawable_name] = {
  33. 'hash' => generate_hash(content),
  34. 'created_at' => Time.now.to_s,
  35. 'content_hash' => Digest::MD5.hexdigest(content)
  36. }
  37. 11 save_registry
  38. end
  39. 1 def find_existing_drawable(content)
  40. content_hash = Digest::MD5.hexdigest(content)
  41. # Search registry for matching content
  42. @registry.each do |name, data|
  43. if data['content_hash'] == content_hash
  44. # Verify file still exists
  45. if drawable_exists?(name)
  46. return name
  47. else
  48. # Clean up orphaned registry entry
  49. @registry.delete(name)
  50. end
  51. end
  52. end
  53. nil
  54. end
  55. 1 def cleanup_orphaned_drawables
  56. orphaned = []
  57. @registry.each do |name, _data|
  58. file_path = File.join(@drawable_dir, "#{name}.xml")
  59. unless File.exist?(file_path)
  60. orphaned << name
  61. end
  62. end
  63. orphaned.each { |name| @registry.delete(name) }
  64. save_registry if orphaned.any?
  65. orphaned
  66. end
  67. 1 def list_drawables
  68. drawables = []
  69. Dir.glob(File.join(@drawable_dir, '*.xml')).each do |file|
  70. name = File.basename(file, '.xml')
  71. next if name == 'ic_launcher_foreground' # Skip system drawables
  72. next if name == 'ic_launcher_background'
  73. drawables << {
  74. name: name,
  75. path: file,
  76. size: File.size(file),
  77. modified: File.mtime(file)
  78. }
  79. end
  80. drawables.sort_by { |d| d[:name] }
  81. end
  82. 1 def get_usage_stats
  83. stats = {
  84. total_drawables: 0,
  85. shape_drawables: 0,
  86. ripple_drawables: 0,
  87. selector_drawables: 0,
  88. total_size: 0,
  89. reuse_count: 0
  90. }
  91. list_drawables.each do |drawable|
  92. stats[:total_drawables] += 1
  93. stats[:total_size] += drawable[:size]
  94. case drawable[:name]
  95. when /^shape_/
  96. stats[:shape_drawables] += 1
  97. when /^ripple_/
  98. stats[:ripple_drawables] += 1
  99. when /^selector_/
  100. stats[:selector_drawables] += 1
  101. end
  102. end
  103. # Count reuses based on session cache
  104. stats[:reuse_count] = @session_cache.size
  105. stats
  106. end
  107. 1 private
  108. 1 def load_registry
  109. 59 return {} unless File.exist?(@registry_path)
  110. begin
  111. JSON.parse(File.read(@registry_path))
  112. rescue JSON::ParserError
  113. {}
  114. end
  115. end
  116. 1 def save_registry
  117. 11 File.write(@registry_path, JSON.pretty_generate(@registry))
  118. rescue => e
  119. puts "Warning: Failed to save drawable registry: #{e.message}"
  120. end
  121. end
  122. end

lib/xml/drawable/ripple_drawable_generator.rb

96.0% lines covered

100 relevant lines. 96 lines covered and 4 lines missed.
    
  1. 1 require_relative '../helpers/resource_resolver'
  2. 1 module DrawableGenerator
  3. 1 class RippleDrawableGenerator
  4. 1 def generate(json_data, component_type)
  5. 26 return nil unless json_data
  6. 25 xml = []
  7. 25 xml << '<?xml version="1.0" encoding="utf-8"?>'
  8. 25 xml << '<ripple xmlns:android="http://schemas.android.com/apk/res/android"'
  9. # Ripple color
  10. 25 ripple_color = determine_ripple_color(json_data, component_type)
  11. 25 xml << " android:color=\"#{ripple_color}\">"
  12. # Content mask and background
  13. 25 if needs_mask?(json_data, component_type)
  14. 10 generate_mask_content(xml, json_data)
  15. else
  16. 15 generate_background_content(xml, json_data)
  17. end
  18. 25 xml << '</ripple>'
  19. 25 xml.join("\n")
  20. end
  21. 1 private
  22. 1 def determine_ripple_color(json_data, component_type)
  23. # Check for explicit ripple color
  24. 25 if json_data['rippleColor']
  25. 1 return parse_color(json_data['rippleColor'])
  26. end
  27. # Check for tap background (can be used as ripple hint)
  28. 24 if json_data['tapBackground']
  29. 1 return parse_color(json_data['tapBackground'])
  30. end
  31. # Default ripple colors based on component type
  32. 23 case component_type
  33. when 'Button'
  34. 11 if json_data['background'] && json_data['background'].start_with?('#')
  35. # Light ripple for dark backgrounds, dark ripple for light
  36. 4 return is_dark_color?(json_data['background']) ? '#40FFFFFF' : '#40000000'
  37. end
  38. 7 return '?attr/colorControlHighlight'
  39. when 'Card', 'ListItem'
  40. 3 return '?attr/colorControlHighlight'
  41. else
  42. # Default semi-transparent ripple
  43. 9 return '#20000000'
  44. end
  45. end
  46. 1 def needs_mask?(json_data, component_type)
  47. # Use mask for borderless ripples or specific components
  48. 30 json_data['rippleBorderless'] == true ||
  49. component_type == 'ImageButton' ||
  50. 24 (component_type == 'Button' && !json_data['background'])
  51. end
  52. 1 def generate_mask_content(xml, json_data)
  53. 10 xml << ' <item android:id="@android:id/mask">'
  54. 10 if json_data['cornerRadius'] || json_data['shape']
  55. 1 xml << ' <shape android:shape="rectangle">'
  56. 1 if json_data['cornerRadius']
  57. 1 radius = parse_dimension(json_data['cornerRadius'])
  58. 1 xml << " <corners android:radius=\"#{radius}\" />"
  59. end
  60. 1 xml << ' <solid android:color="@android:color/white" />'
  61. 1 xml << ' </shape>'
  62. else
  63. 9 xml << ' <color android:color="@android:color/white" />'
  64. end
  65. 10 xml << ' </item>'
  66. end
  67. 1 def generate_background_content(xml, json_data)
  68. # Add background item if specified
  69. 15 if json_data['background'] || json_data['cornerRadius'] || json_data['borderWidth']
  70. 11 xml << ' <item>'
  71. 11 if json_data['cornerRadius'] || json_data['borderWidth']
  72. 3 generate_shape_item(xml, json_data)
  73. 8 elsif json_data['background']
  74. 8 if json_data['background'].start_with?('#')
  75. 7 xml << " <color android:color=\"#{json_data['background']}\" />"
  76. 1 elsif json_data['background'].start_with?('@')
  77. 1 xml << " <color android:color=\"#{json_data['background']}\" />"
  78. else
  79. color = parse_color(json_data['background'])
  80. xml << " <color android:color=\"#{color}\" />"
  81. end
  82. end
  83. 11 xml << ' </item>'
  84. end
  85. end
  86. 1 def generate_shape_item(xml, json_data)
  87. 3 xml << ' <shape android:shape="rectangle">'
  88. # Corner radius
  89. 3 if json_data['cornerRadius']
  90. 2 radius = parse_dimension(json_data['cornerRadius'])
  91. 2 xml << " <corners android:radius=\"#{radius}\" />"
  92. end
  93. # Background color
  94. 3 if json_data['background']
  95. 2 color = parse_color(json_data['background'])
  96. 2 xml << " <solid android:color=\"#{color}\" />"
  97. else
  98. 1 xml << ' <solid android:color="@android:color/transparent" />'
  99. end
  100. # Border
  101. 3 if json_data['borderWidth'] && json_data['borderColor']
  102. 1 width = parse_dimension(json_data['borderWidth'])
  103. 1 color = parse_color(json_data['borderColor'])
  104. 1 xml << ' <stroke'
  105. 1 xml << " android:width=\"#{width}\""
  106. 1 xml << " android:color=\"#{color}\" />"
  107. end
  108. 3 xml << ' </shape>'
  109. end
  110. 1 def is_dark_color?(color_str)
  111. 13 return false unless color_str&.start_with?('#')
  112. # Remove # and parse hex
  113. 11 hex = color_str[1..]
  114. # Handle different hex formats
  115. 11 if hex.length == 6
  116. 8 r = hex[0..1].to_i(16)
  117. 8 g = hex[2..3].to_i(16)
  118. 8 b = hex[4..5].to_i(16)
  119. 3 elsif hex.length == 8
  120. # Skip alpha
  121. 1 r = hex[2..3].to_i(16)
  122. 1 g = hex[4..5].to_i(16)
  123. 1 b = hex[6..7].to_i(16)
  124. 2 elsif hex.length == 3
  125. 2 r = (hex[0] * 2).to_i(16)
  126. 2 g = (hex[1] * 2).to_i(16)
  127. 2 b = (hex[2] * 2).to_i(16)
  128. else
  129. return false
  130. end
  131. # Calculate luminance
  132. 11 luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
  133. 11 luminance < 0.5
  134. end
  135. 1 def parse_dimension(value)
  136. 7 return '0dp' unless value
  137. 6 value_str = value.to_s
  138. # Already has unit
  139. 6 return value_str if value_str =~ /\d+(dp|sp|px|dip|pt|in|mm)$/
  140. # Just a number, add dp
  141. 5 return "#{value_str}dp" if value_str =~ /^\d+$/
  142. value_str
  143. end
  144. 1 def parse_color(value)
  145. 9 return '#000000' unless value
  146. 8 value_str = value.to_s
  147. # Already a color reference
  148. 8 return value_str if value_str.start_with?('@color/', '?attr/')
  149. # Special case for transparent
  150. 6 return '#00000000' if value_str == 'transparent'
  151. # Use ResourceResolver to check for color resources
  152. 5 KjuiTools::Xml::Helpers::ResourceResolver.process_color(value_str)
  153. end
  154. end
  155. end

lib/xml/drawable/shape_drawable_generator.rb

99.06% lines covered

106 relevant lines. 105 lines covered and 1 lines missed.
    
  1. 1 require_relative '../helpers/resource_resolver'
  2. 1 module DrawableGenerator
  3. 1 class ShapeDrawableGenerator
  4. 1 def generate(json_data)
  5. 23 return nil unless json_data
  6. 22 xml = []
  7. 22 xml << '<?xml version="1.0" encoding="utf-8"?>'
  8. # Determine if we need a layer-list for gradient + border
  9. 22 if json_data['gradient'] && json_data['borderWidth']
  10. 2 generate_layered_shape(xml, json_data)
  11. else
  12. 20 generate_simple_shape(xml, json_data)
  13. end
  14. 22 xml.join("\n")
  15. end
  16. 1 private
  17. 1 def generate_simple_shape(xml, json_data)
  18. 20 xml << '<shape xmlns:android="http://schemas.android.com/apk/res/android"'
  19. 20 xml << ' android:shape="rectangle">'
  20. # Corner radius
  21. 20 if json_data['cornerRadius']
  22. 4 radius = parse_dimension(json_data['cornerRadius'])
  23. 4 xml << " <corners android:radius=\"#{radius}\" />"
  24. end
  25. # Background color or gradient
  26. 20 if json_data['gradient']
  27. 5 generate_gradient(xml, json_data['gradient'])
  28. 15 elsif json_data['background']
  29. 6 color = parse_color(json_data['background'])
  30. 6 xml << " <solid android:color=\"#{color}\" />"
  31. end
  32. # Border
  33. 20 if json_data['borderWidth'] && json_data['borderColor']
  34. 1 width = parse_dimension(json_data['borderWidth'])
  35. 1 color = parse_color(json_data['borderColor'])
  36. 1 xml << " <stroke"
  37. 1 xml << " android:width=\"#{width}\""
  38. 1 xml << " android:color=\"#{color}\" />"
  39. end
  40. # Padding
  41. 20 if json_data['padding']
  42. 1 padding = parse_dimension(json_data['padding'])
  43. 1 xml << " <padding"
  44. 1 xml << " android:left=\"#{padding}\""
  45. 1 xml << " android:top=\"#{padding}\""
  46. 1 xml << " android:right=\"#{padding}\""
  47. 1 xml << " android:bottom=\"#{padding}\" />"
  48. 19 elsif json_data['paddingLeft'] || json_data['paddingTop'] ||
  49. json_data['paddingRight'] || json_data['paddingBottom']
  50. 2 xml << " <padding"
  51. 2 xml << " android:left=\"#{parse_dimension(json_data['paddingLeft'] || '0dp')}\""
  52. 2 xml << " android:top=\"#{parse_dimension(json_data['paddingTop'] || '0dp')}\""
  53. 2 xml << " android:right=\"#{parse_dimension(json_data['paddingRight'] || '0dp')}\""
  54. 2 xml << " android:bottom=\"#{parse_dimension(json_data['paddingBottom'] || '0dp')}\" />"
  55. end
  56. 20 xml << '</shape>'
  57. end
  58. 1 def generate_layered_shape(xml, json_data)
  59. 2 xml << '<layer-list xmlns:android="http://schemas.android.com/apk/res/android">'
  60. # Background layer with gradient
  61. 2 xml << ' <item>'
  62. 2 xml << ' <shape android:shape="rectangle">'
  63. 2 if json_data['cornerRadius']
  64. 1 radius = parse_dimension(json_data['cornerRadius'])
  65. 1 xml << " <corners android:radius=\"#{radius}\" />"
  66. end
  67. 2 generate_gradient(xml, json_data['gradient'], ' ')
  68. 2 xml << ' </shape>'
  69. 2 xml << ' </item>'
  70. # Border layer
  71. 2 if json_data['borderWidth'] && json_data['borderColor']
  72. 2 xml << ' <item>'
  73. 2 xml << ' <shape android:shape="rectangle">'
  74. 2 if json_data['cornerRadius']
  75. 1 radius = parse_dimension(json_data['cornerRadius'])
  76. 1 xml << " <corners android:radius=\"#{radius}\" />"
  77. end
  78. 2 width = parse_dimension(json_data['borderWidth'])
  79. 2 color = parse_color(json_data['borderColor'])
  80. 2 xml << " <stroke"
  81. 2 xml << " android:width=\"#{width}\""
  82. 2 xml << " android:color=\"#{color}\" />"
  83. 2 xml << ' </shape>'
  84. 2 xml << ' </item>'
  85. end
  86. 2 xml << '</layer-list>'
  87. end
  88. 1 def generate_gradient(xml, gradient_data, indent = ' ')
  89. 7 return unless gradient_data
  90. # Parse gradient type
  91. 7 gradient_type = gradient_data['type'] || 'linear'
  92. 7 xml << "#{indent}<gradient"
  93. 7 case gradient_type.downcase
  94. when 'linear'
  95. 5 xml << "#{indent} android:type=\"linear\""
  96. 5 angle = gradient_data['angle'] || 0
  97. 5 xml << "#{indent} android:angle=\"#{angle}\""
  98. when 'radial'
  99. 1 xml << "#{indent} android:type=\"radial\""
  100. 1 radius = parse_dimension(gradient_data['radius'] || '100dp')
  101. 1 xml << "#{indent} android:gradientRadius=\"#{radius}\""
  102. when 'sweep'
  103. 1 xml << "#{indent} android:type=\"sweep\""
  104. end
  105. # Colors
  106. 7 if gradient_data['startColor']
  107. 4 color = parse_color(gradient_data['startColor'])
  108. 4 xml << "#{indent} android:startColor=\"#{color}\""
  109. end
  110. 7 if gradient_data['centerColor']
  111. 1 color = parse_color(gradient_data['centerColor'])
  112. 1 xml << "#{indent} android:centerColor=\"#{color}\""
  113. end
  114. 7 if gradient_data['endColor']
  115. 4 color = parse_color(gradient_data['endColor'])
  116. 4 xml << "#{indent} android:endColor=\"#{color}\""
  117. end
  118. # Center position for radial
  119. 7 if gradient_type.downcase == 'radial'
  120. 1 centerX = gradient_data['centerX'] || 0.5
  121. 1 centerY = gradient_data['centerY'] || 0.5
  122. 1 xml << "#{indent} android:centerX=\"#{centerX}\""
  123. 1 xml << "#{indent} android:centerY=\"#{centerY}\""
  124. end
  125. 7 xml << " />"
  126. end
  127. 1 def parse_dimension(value)
  128. 25 return '0dp' unless value
  129. 24 value_str = value.to_s
  130. # Already has unit
  131. 24 return value_str if value_str =~ /\d+(dp|sp|px|dip|pt|in|mm)$/
  132. # Just a number, add dp
  133. 17 return "#{value_str}dp" if value_str =~ /^\d+$/
  134. value_str
  135. end
  136. 1 def parse_color(value)
  137. 21 return '#000000' unless value
  138. 20 value_str = value.to_s
  139. # Already a color reference
  140. 20 return value_str if value_str.start_with?('@color/')
  141. # Special case for transparent
  142. 19 return '#00000000' if value_str == 'transparent'
  143. # Use ResourceResolver to check for color resources
  144. 18 KjuiTools::Xml::Helpers::ResourceResolver.process_color(value_str)
  145. end
  146. end
  147. end

lib/xml/drawable/state_list_drawable_generator.rb

78.18% lines covered

110 relevant lines. 86 lines covered and 24 lines missed.
    
  1. 1 require_relative '../helpers/resource_resolver'
  2. 1 module DrawableGenerator
  3. 1 class StateListDrawableGenerator
  4. 1 def generate(json_data, parent_generator)
  5. 20 return nil unless json_data
  6. 19 @parent_generator = parent_generator
  7. 19 xml = []
  8. 19 xml << '<?xml version="1.0" encoding="utf-8"?>'
  9. 19 xml << '<selector xmlns:android="http://schemas.android.com/apk/res/android">'
  10. # Order matters in state list - most specific states first
  11. # Disabled state
  12. 19 if json_data['disabledBackground'] || json_data['disabledColor']
  13. 3 generate_state_item(xml,
  14. state: 'disabled',
  15. background: json_data['disabledBackground'],
  16. color: json_data['disabledColor'],
  17. original_data: json_data
  18. )
  19. end
  20. # Pressed/Tap state
  21. 19 if json_data['tapBackground'] || json_data['pressedBackground'] || json_data['tapColor']
  22. 3 generate_state_item(xml,
  23. state: 'pressed',
  24. background: json_data['tapBackground'] || json_data['pressedBackground'],
  25. color: json_data['tapColor'],
  26. original_data: json_data
  27. )
  28. end
  29. # Selected state
  30. 19 if json_data['selectedBackground'] || json_data['selectedColor']
  31. 2 generate_state_item(xml,
  32. state: 'selected',
  33. background: json_data['selectedBackground'],
  34. color: json_data['selectedColor'],
  35. original_data: json_data
  36. )
  37. end
  38. # Focused state
  39. 19 if json_data['focusedBackground'] || json_data['focusedColor']
  40. 2 generate_state_item(xml,
  41. state: 'focused',
  42. background: json_data['focusedBackground'],
  43. color: json_data['focusedColor'],
  44. original_data: json_data
  45. )
  46. end
  47. # Checked state (for checkboxes, radio buttons, switches)
  48. 19 if json_data['checkedBackground'] || json_data['checkedColor']
  49. 2 generate_state_item(xml,
  50. state: 'checked',
  51. background: json_data['checkedBackground'],
  52. color: json_data['checkedColor'],
  53. original_data: json_data
  54. )
  55. end
  56. # Default state (always last)
  57. 19 generate_default_state(xml, json_data)
  58. 19 xml << '</selector>'
  59. 19 xml.join("\n")
  60. end
  61. 1 private
  62. 1 def generate_state_item(xml, state:, background:, color:, original_data:)
  63. 12 return unless background || color
  64. # Build state attributes
  65. 12 state_attrs = build_state_attributes(state)
  66. 12 xml << " <item #{state_attrs}>"
  67. 12 if background
  68. 7 if needs_shape?(background, original_data)
  69. # Generate a shape drawable for this state
  70. generate_state_shape(xml, background, original_data)
  71. else
  72. # Simple color
  73. 7 color_value = parse_color(background)
  74. 7 xml << " <color android:color=\"#{color_value}\" />"
  75. end
  76. 5 elsif color
  77. # Text color selector item
  78. 5 color_value = parse_color(color)
  79. 5 xml << " <color android:color=\"#{color_value}\" />"
  80. end
  81. 12 xml << ' </item>'
  82. end
  83. 1 def generate_default_state(xml, json_data)
  84. 19 xml << ' <item>'
  85. 19 if json_data['background'] || json_data['cornerRadius'] || json_data['borderWidth']
  86. 4 if needs_shape?(json_data['background'], json_data)
  87. 2 generate_state_shape(xml, json_data['background'], json_data)
  88. else
  89. # Simple color background
  90. 2 color = parse_color(json_data['background'] || '#FFFFFF')
  91. 2 xml << " <color android:color=\"#{color}\" />"
  92. end
  93. else
  94. # Transparent default
  95. 15 xml << ' <color android:color="@android:color/transparent" />'
  96. end
  97. 19 xml << ' </item>'
  98. end
  99. 1 def generate_state_shape(xml, background, original_data)
  100. 2 xml << ' <shape android:shape="rectangle">'
  101. # Corner radius from original data
  102. 2 if original_data['cornerRadius']
  103. 1 radius = parse_dimension(original_data['cornerRadius'])
  104. 1 xml << " <corners android:radius=\"#{radius}\" />"
  105. end
  106. # Background color or gradient
  107. 2 if background.is_a?(Hash) && background['gradient']
  108. generate_gradient(xml, background['gradient'])
  109. 2 elsif background
  110. 2 color = parse_color(background)
  111. 2 xml << " <solid android:color=\"#{color}\" />"
  112. end
  113. # Border from original data (consistent across states)
  114. 2 if original_data['borderWidth'] && original_data['borderColor']
  115. 1 width = parse_dimension(original_data['borderWidth'])
  116. 1 color = parse_color(original_data['borderColor'])
  117. 1 xml << ' <stroke'
  118. 1 xml << " android:width=\"#{width}\""
  119. 1 xml << " android:color=\"#{color}\" />"
  120. end
  121. 2 xml << ' </shape>'
  122. end
  123. 1 def generate_gradient(xml, gradient_data)
  124. return unless gradient_data
  125. gradient_type = gradient_data['type'] || 'linear'
  126. xml << ' <gradient'
  127. case gradient_type.downcase
  128. when 'linear'
  129. xml << ' android:type="linear"'
  130. angle = gradient_data['angle'] || 0
  131. xml << " android:angle=\"#{angle}\""
  132. when 'radial'
  133. xml << ' android:type="radial"'
  134. radius = parse_dimension(gradient_data['radius'] || '100dp')
  135. xml << " android:gradientRadius=\"#{radius}\""
  136. when 'sweep'
  137. xml << ' android:type="sweep"'
  138. end
  139. # Colors
  140. if gradient_data['startColor']
  141. color = parse_color(gradient_data['startColor'])
  142. xml << " android:startColor=\"#{color}\""
  143. end
  144. if gradient_data['centerColor']
  145. color = parse_color(gradient_data['centerColor'])
  146. xml << " android:centerColor=\"#{color}\""
  147. end
  148. if gradient_data['endColor']
  149. color = parse_color(gradient_data['endColor'])
  150. xml << " android:endColor=\"#{color}\""
  151. end
  152. xml << ' />'
  153. end
  154. 1 def build_state_attributes(state)
  155. 19 case state
  156. when 'disabled'
  157. 4 'android:state_enabled="false"'
  158. when 'pressed'
  159. 4 'android:state_pressed="true"'
  160. when 'selected'
  161. 3 'android:state_selected="true"'
  162. when 'focused'
  163. 3 'android:state_focused="true"'
  164. when 'checked'
  165. 3 'android:state_checked="true"'
  166. when 'activated'
  167. 1 'android:state_activated="true"'
  168. else
  169. 1 ''
  170. end
  171. end
  172. 1 def needs_shape?(background, original_data)
  173. 15 return true if original_data['cornerRadius']
  174. 13 return true if original_data['borderWidth']
  175. 11 return true if background.is_a?(Hash) && background['gradient']
  176. 10 false
  177. end
  178. 1 def parse_dimension(value)
  179. 5 return '0dp' unless value
  180. 4 value_str = value.to_s
  181. # Already has unit
  182. 4 return value_str if value_str =~ /\d+(dp|sp|px|dip|pt|in|mm)$/
  183. # Just a number, add dp
  184. 3 return "#{value_str}dp" if value_str =~ /^\d+$/
  185. value_str
  186. end
  187. 1 def parse_color(value)
  188. 21 return '#000000' unless value
  189. 20 value_str = value.to_s
  190. # Already a color reference
  191. 20 return value_str if value_str.start_with?('@color/', '?attr/')
  192. # Special case for transparent
  193. 18 return '#00000000' if value_str == 'transparent'
  194. # Use ResourceResolver to check for color resources
  195. 17 KjuiTools::Xml::Helpers::ResourceResolver.process_color(value_str)
  196. end
  197. end
  198. end

lib/xml/helpers/attribute_mapper.rb

92.11% lines covered

76 relevant lines. 70 lines covered and 6 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 require_relative 'mappers/dimension_mapper'
  4. 1 require_relative 'mappers/text_mapper'
  5. 1 require_relative 'mappers/layout_mapper'
  6. 1 require_relative 'mappers/style_mapper'
  7. 1 require_relative 'mappers/input_mapper'
  8. 1 module XmlGenerator
  9. 1 class AttributeMapper
  10. 1 def initialize(drawable_generator = nil, string_resource_manager = nil)
  11. 64 @dimension_mapper = Mappers::DimensionMapper.new
  12. 64 @text_mapper = Mappers::TextMapper.new(string_resource_manager)
  13. 64 @layout_mapper = Mappers::LayoutMapper.new(@dimension_mapper)
  14. 64 @style_mapper = Mappers::StyleMapper.new(@text_mapper, drawable_generator)
  15. 64 @input_mapper = Mappers::InputMapper.new
  16. 64 @drawable_generator = drawable_generator
  17. 64 @string_resource_manager = string_resource_manager
  18. 64 @attribute_map = create_attribute_map
  19. end
  20. 1 def map_dimension(value)
  21. 30 @dimension_mapper.map_dimension(value)
  22. end
  23. 1 def map_attribute(key, value, component_type, parent_type = nil, json_element = nil)
  24. # Skip problematic data binding expressions for specific attributes
  25. 25 if should_skip_binding?(key, value, component_type)
  26. 4 log_skipped_binding(key, value, component_type)
  27. 4 puts "Skipping binding: #{key}=#{value} for #{component_type}" if ENV['DEBUG']
  28. 4 return nil
  29. end
  30. # Try layout attributes first (includes dimensions, padding, margin, alignment)
  31. 21 result = @layout_mapper.map_layout_attributes(key, value, component_type, parent_type)
  32. 21 return result if result
  33. # Try alignment attributes
  34. 20 result = @layout_mapper.map_alignment_attributes(key, value, parent_type)
  35. 20 return result if result
  36. # Try text attributes
  37. 18 result = @text_mapper.map_text_attributes(key, value, component_type)
  38. 18 return result if result
  39. # Try style attributes (with json_element for drawable generation)
  40. 15 result = @style_mapper.map_style_attributes(key, value, json_element, component_type)
  41. 15 return result if result
  42. # Try input attributes
  43. 14 result = @input_mapper.map_input_attributes(key, value)
  44. 14 return result if result
  45. # Custom component properties (store as tag or tools attribute)
  46. 14 case key
  47. when 'title'
  48. # Don't use tools: namespace for data binding expressions
  49. 2 if value.to_s.start_with?('@{')
  50. 1 return nil # Skip tools attributes with data binding
  51. end
  52. 1 return { namespace: 'tools', name: 'title', value: value }
  53. when 'count'
  54. # Don't use tools: namespace for data binding expressions
  55. 1 if value.to_s.start_with?('@{')
  56. return nil # Skip tools attributes with data binding
  57. end
  58. 1 return { namespace: 'tools', name: 'count', value: value.to_s }
  59. when /^constraint/
  60. 3 return map_constraint_attribute(key, value)
  61. else
  62. # Check if it's in the standard map
  63. 8 if @attribute_map[key]
  64. 5 mapped = @attribute_map[key]
  65. return {
  66. 5 namespace: mapped[:namespace] || 'android',
  67. name: mapped[:name],
  68. value: convert_value(value, mapped[:type])
  69. }
  70. end
  71. end
  72. nil
  73. end
  74. 1 private
  75. 1 def should_skip_binding?(key, value, component_type)
  76. 25 return false unless value.to_s.include?('@{')
  77. # List of problematic bindings that need to be skipped
  78. problematic_bindings = [
  79. # RecyclerView items binding - Skip this as it needs complex adapter implementation
  80. 5 { key: 'items', component: 'RecyclerView' },
  81. { key: 'items', component: 'Collection' },
  82. # StatusColor binding - Compose UI Color type not supported in data binding
  83. { key: 'tint', value_contains: 'statusColor' },
  84. { key: 'color', value_contains: 'statusColor' }, # color is sometimes mapped to tint
  85. # Visibility binding - String type not supported
  86. { key: 'visibility', value_contains: '@{' },
  87. # Progress binding - double type not supported
  88. { key: 'progress', value_contains: '@{' },
  89. # Slider value binding (maps to progress) - double type not supported
  90. { key: 'value', component: 'Slider', value_contains: '@{' }
  91. ]
  92. 5 problematic_bindings.any? do |binding|
  93. 22 if binding[:component]
  94. 10 key == binding[:key] && component_type&.include?(binding[:component])
  95. 12 elsif binding[:value_contains]
  96. 12 key == binding[:key] && value.to_s.include?(binding[:value_contains])
  97. elsif binding[:type]
  98. key == binding[:key] && value.to_s.include?('.') # Assumes object property access
  99. else
  100. key == binding[:key]
  101. end
  102. end
  103. end
  104. 1 def log_skipped_binding(key, value, component_type)
  105. 4 @skipped_bindings ||= []
  106. 4 @skipped_bindings << {
  107. attribute: key,
  108. value: value,
  109. component: component_type,
  110. reason: 'Requires custom binding adapter'
  111. }
  112. # Write to a file that can be accessed later
  113. 4 File.open('/tmp/skipped_bindings.json', 'w') do |f|
  114. 4 f.write(@skipped_bindings.to_json)
  115. end
  116. end
  117. 1 def create_attribute_map
  118. {
  119. # Additional mappings not covered by specific mappers
  120. 64 'contentDescription' => { name: 'contentDescription', type: 'string' },
  121. 'tag' => { name: 'tag', type: 'string' },
  122. 'transitionName' => { name: 'transitionName', type: 'string' },
  123. 'elevation' => { name: 'elevation', type: 'dimension' },
  124. 'translationZ' => { name: 'translationZ', type: 'dimension' },
  125. 'rotation' => { name: 'rotation', type: 'float' },
  126. 'rotationX' => { name: 'rotationX', type: 'float' },
  127. 'rotationY' => { name: 'rotationY', type: 'float' },
  128. 'scaleX' => { name: 'scaleX', type: 'float' },
  129. 'scaleY' => { name: 'scaleY', type: 'float' }
  130. }
  131. end
  132. 1 def convert_value(value, type)
  133. 5 case type
  134. when 'dimension'
  135. 1 @dimension_mapper.convert_dimension(value)
  136. when 'float'
  137. 2 value.to_f.to_s
  138. when 'integer'
  139. value.to_i.to_s
  140. when 'boolean'
  141. value.to_s
  142. else
  143. 2 value
  144. end
  145. end
  146. 1 def map_constraint_attribute(key, value)
  147. # ConstraintLayout attributes mapping
  148. constraint_map = {
  149. 3 'constraintStartToStartOf' => 'layout_constraintStart_toStartOf',
  150. 'constraintEndToEndOf' => 'layout_constraintEnd_toEndOf',
  151. 'constraintTopToTopOf' => 'layout_constraintTop_toTopOf',
  152. 'constraintBottomToBottomOf' => 'layout_constraintBottom_toBottomOf',
  153. 'constraintStartToEndOf' => 'layout_constraintStart_toEndOf',
  154. 'constraintEndToStartOf' => 'layout_constraintEnd_toStartOf',
  155. 'constraintTopToBottomOf' => 'layout_constraintTop_toBottomOf',
  156. 'constraintBottomToTopOf' => 'layout_constraintBottom_toTopOf'
  157. }
  158. 3 if constraint_map[key]
  159. 3 constraint_value = value == 'parent' ? 'parent' : "@id/#{value}"
  160. 3 return { namespace: 'app', name: constraint_map[key], value: constraint_value }
  161. end
  162. nil
  163. end
  164. end
  165. end

lib/xml/helpers/binding_parser.rb

75.9% lines covered

83 relevant lines. 63 lines covered and 20 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 module XmlGenerator
  3. 1 class BindingParser
  4. 1 def initialize
  5. 34 @bindings = []
  6. end
  7. 1 def parse(value)
  8. # Convert @{variable} syntax to Android data binding
  9. 11 if value.start_with?('@{') && value.end_with?('}')
  10. # Extract the binding expression
  11. 10 expression = value[2..-2]
  12. # Track the binding
  13. 10 @bindings << expression
  14. # Return Android data binding format
  15. 10 "@{#{convert_expression(expression)}}"
  16. else
  17. 1 value
  18. end
  19. end
  20. 1 def get_bindings
  21. 1 @bindings.uniq
  22. end
  23. 1 def has_bindings?
  24. 2 !@bindings.empty?
  25. end
  26. 1 private
  27. 1 def convert_expression(expression)
  28. # Handle different binding patterns
  29. # Simple variable binding: @{userName} -> @{data.userName}
  30. 10 if expression.match?(/^\w+$/)
  31. 5 return "data.#{expression}"
  32. end
  33. # Property access: @{user.name} -> @{data.user.name}
  34. 5 if expression.match?(/^[\w.]+$/)
  35. 1 return "data.#{expression}"
  36. end
  37. # Method call: @{getUserName()} -> @{viewModel.getUserName()}
  38. 4 if expression.include?('(')
  39. 2 if expression.start_with?('viewModel.')
  40. 1 return expression
  41. else
  42. 1 return "viewModel.#{expression}"
  43. end
  44. end
  45. # Conditional expression: @{isVisible ? View.VISIBLE : View.GONE}
  46. 2 if expression.include?('?')
  47. 1 return process_conditional(expression)
  48. end
  49. # String concatenation: @{`Hello ${userName}`}
  50. 1 if expression.include?('${')
  51. 1 return process_string_template(expression)
  52. end
  53. # Default: return as is
  54. expression
  55. end
  56. 1 def process_conditional(expression)
  57. # Convert conditional expressions
  58. 1 parts = expression.split(/\s*\?\s*/)
  59. 1 if parts.length == 2
  60. 1 condition = parts[0]
  61. 1 values = parts[1].split(/\s*:\s*/)
  62. 1 if values.length == 2
  63. # Add data. prefix to condition if it's a simple variable
  64. 1 if condition.match?(/^\w+$/)
  65. 1 condition = "data.#{condition}"
  66. end
  67. # Process visibility values
  68. 1 true_value = process_value(values[0])
  69. 1 false_value = process_value(values[1])
  70. 1 return "#{condition} ? #{true_value} : #{false_value}"
  71. end
  72. end
  73. expression
  74. end
  75. 1 def process_string_template(expression)
  76. # Convert string template: `Hello ${userName}` -> @{`Hello ` + data.userName}
  77. 1 if expression.start_with?('`') && expression.end_with?('`')
  78. 1 template = expression[1..-2]
  79. # Replace ${variable} with ` + data.variable + `
  80. 1 template.gsub!(/\$\{(\w+)\}/) do |match|
  81. 1 "` + data.#{$1} + `"
  82. end
  83. 1 "`#{template}`"
  84. else
  85. expression
  86. end
  87. end
  88. 1 def process_value(value)
  89. # Process special values
  90. 2 case value.strip
  91. when 'true', 'false'
  92. value
  93. when 'VISIBLE', 'View.VISIBLE'
  94. 1 'View.VISIBLE'
  95. when 'INVISIBLE', 'View.INVISIBLE'
  96. 'View.INVISIBLE'
  97. when 'GONE', 'View.GONE'
  98. 1 'View.GONE'
  99. else
  100. # Check if it's a simple variable
  101. if value.match?(/^\w+$/)
  102. "data.#{value}"
  103. else
  104. value
  105. end
  106. end
  107. end
  108. end
  109. 1 class DataBindingManager
  110. 1 def initialize
  111. 3 @variables = Set.new
  112. 3 @imports = Set.new
  113. 3 @converters = []
  114. end
  115. 1 def add_variable(name, type = 'String')
  116. 1 @variables.add({ name: name, type: type })
  117. end
  118. 1 def add_import(class_name)
  119. 1 @imports.add(class_name)
  120. end
  121. 1 def add_converter(converter)
  122. 1 @converters << converter
  123. end
  124. 1 def generate_data_binding_layout(xml_content)
  125. # Wrap the layout in <layout> tags for data binding
  126. doc = Nokogiri::XML(xml_content)
  127. # Create new document with layout root
  128. builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
  129. xml.layout('xmlns:android' => 'http://schemas.android.com/apk/res/android',
  130. 'xmlns:app' => 'http://schemas.android.com/apk/res-auto',
  131. 'xmlns:tools' => 'http://schemas.android.com/tools') do
  132. # Add data section
  133. xml.data do
  134. # Add imports
  135. @imports.each do |import|
  136. xml.import(type: import)
  137. end
  138. # Add variables
  139. @variables.each do |var|
  140. xml.variable(name: var[:name], type: var[:type])
  141. end
  142. # Add ViewModel variable
  143. xml.variable(name: 'viewModel', type: "com.example.viewmodel.#{get_view_model_name}")
  144. end
  145. # Add the original layout content (without XML declaration)
  146. xml << doc.root.to_xml
  147. end
  148. end
  149. builder.to_xml(indent: 4)
  150. end
  151. 1 private
  152. 1 def get_view_model_name
  153. # Generate ViewModel class name from layout name
  154. # This should be passed in or configured
  155. 'MainViewModel'
  156. end
  157. end
  158. end

lib/xml/helpers/component_mapper.rb

93.94% lines covered

33 relevant lines. 31 lines covered and 2 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 module XmlGenerator
  3. 1 class ComponentMapper
  4. 1 def initialize
  5. @component_map = {
  6. # Layout containers
  7. # Note: 'View' is handled specially in map_component method
  8. 61 'HStack' => 'LinearLayout',
  9. 'VStack' => 'LinearLayout',
  10. 'ZStack' => 'FrameLayout',
  11. 'RelativeView' => 'RelativeLayout',
  12. 'ConstraintView' => 'androidx.constraintlayout.widget.ConstraintLayout',
  13. 'ScrollView' => 'ScrollView',
  14. 'HorizontalScrollView' => 'HorizontalScrollView',
  15. # Basic components - Use Kjui custom views for font support
  16. 'Label' => 'com.kotlinjsonui.views.KjuiTextView',
  17. 'Text' => 'com.kotlinjsonui.views.KjuiTextView',
  18. 'Button' => 'com.kotlinjsonui.views.KjuiButton',
  19. 'ImageButton' => 'ImageButton',
  20. 'TextField' => 'com.kotlinjsonui.views.KjuiEditText',
  21. 'SecureField' => 'com.kotlinjsonui.views.KjuiEditText',
  22. 'TextView' => 'com.kotlinjsonui.views.KjuiEditText',
  23. # Images
  24. 'Image' => 'ImageView',
  25. 'NetworkImage' => 'com.kotlinjsonui.views.KjuiNetworkImageView',
  26. 'CircleImage' => 'com.kotlinjsonui.views.KjuiCircleImageView',
  27. # Selection components
  28. 'Switch' => 'Switch',
  29. 'Checkbox' => 'CheckBox',
  30. 'Radio' => 'RadioButton',
  31. 'RadioGroup' => 'RadioGroup',
  32. 'Segment' => 'com.google.android.material.tabs.TabLayout',
  33. 'Picker' => 'Spinner',
  34. 'SelectBox' => 'com.kotlinjsonui.views.KjuiSelectBox',
  35. 'DatePicker' => 'DatePicker',
  36. 'TimePicker' => 'TimePicker',
  37. # Progress
  38. 'ProgressBar' => 'ProgressBar',
  39. 'Slider' => 'SeekBar',
  40. 'Rating' => 'RatingBar',
  41. # Lists
  42. 'List' => 'androidx.recyclerview.widget.RecyclerView',
  43. 'Table' => 'androidx.recyclerview.widget.RecyclerView',
  44. 'Collection' => 'androidx.recyclerview.widget.RecyclerView',
  45. 'Grid' => 'GridLayout',
  46. # Material Design components
  47. 'Card' => 'com.google.android.material.card.MaterialCardView',
  48. 'Chip' => 'com.google.android.material.chip.Chip',
  49. 'ChipGroup' => 'com.google.android.material.chip.ChipGroup',
  50. 'FloatingActionButton' => 'com.google.android.material.floatingactionbutton.FloatingActionButton',
  51. 'BottomNavigation' => 'com.google.android.material.bottomnavigation.BottomNavigationView',
  52. 'NavigationView' => 'com.google.android.material.navigation.NavigationView',
  53. 'AppBar' => 'com.google.android.material.appbar.AppBarLayout',
  54. 'Toolbar' => 'androidx.appcompat.widget.Toolbar',
  55. 'TabLayout' => 'com.google.android.material.tabs.TabLayout',
  56. 'TabView' => 'com.google.android.material.tabs.TabLayout',
  57. # Special components
  58. 'SafeAreaView' => 'com.kotlinjsonui.views.KjuiSafeAreaView',
  59. 'GradientView' => 'com.kotlinjsonui.views.KjuiGradientView',
  60. 'BlurView' => 'com.kotlinjsonui.views.KjuiBlurView',
  61. 'WebView' => 'WebView',
  62. 'VideoView' => 'VideoView',
  63. 'MapView' => 'com.google.android.gms.maps.MapView',
  64. 'AdView' => 'com.google.android.gms.ads.AdView',
  65. # Dividers and spacers
  66. 'Divider' => 'View',
  67. 'Spacer' => 'Space',
  68. # Custom components (will be replaced with includes)
  69. 'Include' => 'include'
  70. }
  71. end
  72. 1 def map_component(type, json_element = nil)
  73. # Special handling for View type
  74. 21 if type == 'View' && json_element
  75. # Check if orientation is specified
  76. 5 if json_element['orientation']
  77. 1 return 'LinearLayout'
  78. else
  79. # Use ConstraintLayout instead of RelativeLayout for better positioning support
  80. 4 return 'androidx.constraintlayout.widget.ConstraintLayout'
  81. end
  82. end
  83. # Check for custom component prefix
  84. 16 if type.start_with?('Custom')
  85. 1 return 'include'
  86. end
  87. # For unknown types, check if they have children
  88. 15 if !@component_map[type] && json_element && (json_element['child'] || json_element['children'])
  89. 1 return 'FrameLayout'
  90. end
  91. 14 @component_map[type] || 'View'
  92. end
  93. 1 def is_container?(type)
  94. 8 containers = ['View', 'HStack', 'VStack', 'ZStack', 'ScrollView',
  95. 'HorizontalScrollView', 'RelativeView', 'ConstraintView',
  96. 'Card', 'List', 'Table', 'Collection', 'Grid',
  97. 'RadioGroup', 'ChipGroup']
  98. 8 containers.include?(type)
  99. end
  100. 1 def needs_adapter?(type)
  101. 4 ['List', 'Table', 'Collection', 'RecyclerView'].include?(type)
  102. end
  103. 1 def is_material_component?(android_class)
  104. 2 android_class.include?('com.google.android.material')
  105. end
  106. 1 def get_layout_params_class(parent_type)
  107. 4 case parent_type
  108. when 'RelativeLayout', 'RelativeView'
  109. 1 'RelativeLayout.LayoutParams'
  110. when 'LinearLayout', 'View', 'HStack', 'VStack'
  111. 1 'LinearLayout.LayoutParams'
  112. when 'FrameLayout', 'ZStack'
  113. 1 'FrameLayout.LayoutParams'
  114. when 'ConstraintLayout', 'ConstraintView'
  115. 1 'ConstraintLayout.LayoutParams'
  116. when 'GridLayout', 'Grid'
  117. 'GridLayout.LayoutParams'
  118. else
  119. 'ViewGroup.LayoutParams'
  120. end
  121. end
  122. 1 def get_orientation(type)
  123. 3 case type
  124. when 'HStack'
  125. 1 'horizontal'
  126. when 'VStack', 'View'
  127. 1 'vertical'
  128. else
  129. nil
  130. end
  131. end
  132. end
  133. end

lib/xml/helpers/data_binding_helper.rb

100.0% lines covered

12 relevant lines. 12 lines covered and 0 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 module XmlGenerator
  3. 1 class DataBindingHelper
  4. 1 def self.process_data_binding(value)
  5. 11 return nil if value.nil?
  6. # Convert @{variable} to Android data binding format
  7. 10 if value.is_a?(String) && value.start_with?('@{') && value.end_with?('}')
  8. # Already in binding format, just ensure proper data. prefix
  9. 7 expr = value[2..-2]
  10. # Add data. prefix if it's a simple variable
  11. 7 if expr.match?(/^\w+$/)
  12. 2 "@{data.#{expr}}"
  13. 5 elsif expr.include?('(') && !expr.include?('viewModel.')
  14. # Method call without viewModel prefix
  15. 2 "@{viewModel.#{expr}}"
  16. else
  17. # Keep as is (already has proper prefix or is complex expression)
  18. 3 value
  19. end
  20. else
  21. 3 value
  22. end
  23. end
  24. end
  25. end

lib/xml/helpers/layout_attribute_processor.rb

76.34% lines covered

93 relevant lines. 71 lines covered and 22 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require_relative 'data_binding_helper'
  3. 1 module XmlGenerator
  4. 1 class LayoutAttributeProcessor
  5. 1 def initialize(attribute_mapper)
  6. 42 @attribute_mapper = attribute_mapper
  7. end
  8. # Process layout dimensions with weight support
  9. 1 def process_dimensions(json_element, is_root, parent_orientation)
  10. 13 attrs = {}
  11. 13 has_weight = json_element['weight']
  12. # Default dimensions
  13. 13 default_width = 'wrap_content'
  14. 13 default_height = 'wrap_content'
  15. # If root element, default to match_parent
  16. 13 if is_root
  17. 4 default_width = 'match_parent'
  18. 4 default_height = 'match_parent'
  19. # If weight is specified, set the dimension in the orientation direction to 0dp
  20. 9 elsif has_weight && parent_orientation
  21. 4 if parent_orientation == 'horizontal'
  22. 2 default_width = '0dp' if !json_element['width']
  23. 2 elsif parent_orientation == 'vertical'
  24. 2 default_height = '0dp' if !json_element['height']
  25. end
  26. end
  27. 13 attrs['android:layout_width'] = @attribute_mapper.map_dimension(
  28. json_element['width'] || default_width
  29. )
  30. 13 attrs['android:layout_height'] = @attribute_mapper.map_dimension(
  31. json_element['height'] || default_height
  32. )
  33. 13 attrs
  34. end
  35. # Process all attributes with gravity combination support
  36. 1 def process_attributes(json_element, parent_type)
  37. 12 attrs = {}
  38. 12 gravity_values = []
  39. 12 constraint_extras = []
  40. 12 has_constraint_specified = false
  41. # Check if parent is ConstraintLayout
  42. 12 is_constraint_layout = parent_type&.include?('ConstraintLayout')
  43. # Track which constraints have been set
  44. 12 constraint_flags = {
  45. horizontal: false,
  46. vertical: false
  47. }
  48. # Map all attributes
  49. 12 json_element.each do |key, value|
  50. 23 next if ['type', 'child', 'children', 'id', 'width', 'height', 'style', 'data', 'orientation'].include?(key)
  51. 9 android_attr = @attribute_mapper.map_attribute(key, value, json_element['type'], parent_type, json_element)
  52. 9 if android_attr
  53. 7 namespace, attr_name = android_attr[:namespace], android_attr[:name]
  54. 7 attr_value = android_attr[:value]
  55. 7 extra = android_attr[:extra]
  56. # Handle data binding
  57. 7 if attr_value.is_a?(String) && attr_value.start_with?('@{')
  58. attr_value = DataBindingHelper.process_data_binding(attr_value)
  59. end
  60. # Track if constraint attributes are being set
  61. 7 if is_constraint_layout && namespace == 'app'
  62. 2 if attr_name.include?('constraint')
  63. 2 has_constraint_specified = true
  64. # Track horizontal constraints
  65. 2 if attr_name.include?('Start') || attr_name.include?('End') || attr_name.include?('Left') || attr_name.include?('Right')
  66. 1 constraint_flags[:horizontal] = true
  67. end
  68. # Track vertical constraints
  69. 2 if attr_name.include?('Top') || attr_name.include?('Bottom')
  70. 1 constraint_flags[:vertical] = true
  71. end
  72. end
  73. end
  74. # Check for alignment attributes that map to constraints
  75. 7 if is_constraint_layout && ['alignLeft', 'alignRight', 'alignTop', 'alignBottom', 'alignCenterHorizontal', 'alignCenterVertical', 'alignCenterInParent'].include?(key)
  76. 2 has_constraint_specified = true
  77. 2 if key == 'alignLeft' || key == 'alignRight' || key == 'alignCenterHorizontal'
  78. 1 constraint_flags[:horizontal] = true
  79. end
  80. 2 if key == 'alignTop' || key == 'alignBottom' || key == 'alignCenterVertical'
  81. 1 constraint_flags[:vertical] = true
  82. end
  83. 2 if key == 'alignCenterInParent'
  84. constraint_flags[:horizontal] = true
  85. constraint_flags[:vertical] = true
  86. end
  87. end
  88. # Collect gravity values to combine them
  89. 7 if attr_name == 'layout_gravity' && parent_type == 'LinearLayout'
  90. gravity_values << attr_value if value
  91. 7 elsif extra && is_constraint_layout
  92. # Handle special ConstraintLayout cases that need multiple attributes
  93. constraint_extras << { key: key, value: value, extra: extra }
  94. # Still add the primary attribute
  95. if namespace == 'android'
  96. attrs["android:#{attr_name}"] = attr_value
  97. elsif namespace == 'app'
  98. attrs["app:#{attr_name}"] = attr_value
  99. end
  100. else
  101. 7 if namespace == 'android'
  102. 5 attrs["android:#{attr_name}"] = attr_value
  103. 2 elsif namespace == 'app'
  104. 2 attrs["app:#{attr_name}"] = attr_value
  105. elsif namespace == 'tools'
  106. attrs["tools:#{attr_name}"] = attr_value
  107. else
  108. attrs[attr_name] = attr_value
  109. end
  110. end
  111. end
  112. end
  113. # Process ConstraintLayout special cases
  114. 12 if is_constraint_layout && constraint_extras.any?
  115. constraint_extras.each do |item|
  116. case item[:extra]
  117. when 'center_horizontal'
  118. # Add end constraint for horizontal centering
  119. attrs['app:layout_constraintEnd_toEndOf'] = 'parent'
  120. when 'center_vertical'
  121. # Add bottom constraint for vertical centering
  122. attrs['app:layout_constraintBottom_toBottomOf'] = 'parent'
  123. when 'center_in_parent'
  124. # Add all constraints for centering in parent
  125. attrs['app:layout_constraintEnd_toEndOf'] = 'parent'
  126. attrs['app:layout_constraintTop_toTopOf'] = 'parent'
  127. attrs['app:layout_constraintBottom_toBottomOf'] = 'parent'
  128. when 'center_vertical_to_view'
  129. # Add bottom constraint to same view for vertical centering
  130. attrs['app:layout_constraintBottom_toBottomOf'] = "@id/#{item[:value]}"
  131. when 'center_horizontal_to_view'
  132. # Add end constraint to same view for horizontal centering
  133. attrs['app:layout_constraintEnd_toEndOf'] = "@id/#{item[:value]}"
  134. end
  135. end
  136. end
  137. # Add default constraints for ConstraintLayout if none specified
  138. 12 if is_constraint_layout
  139. # Add default horizontal constraint (top-left) if no horizontal constraint specified
  140. 6 if !constraint_flags[:horizontal]
  141. 5 attrs['app:layout_constraintStart_toStartOf'] = 'parent'
  142. end
  143. # Add default vertical constraint (top) if no vertical constraint specified
  144. 6 if !constraint_flags[:vertical]
  145. 5 attrs['app:layout_constraintTop_toTopOf'] = 'parent'
  146. end
  147. end
  148. # Combine gravity values if there are multiple
  149. 12 if gravity_values.any?
  150. attrs['android:layout_gravity'] = gravity_values.join('|')
  151. end
  152. 12 attrs
  153. end
  154. # Process LinearLayout orientation
  155. 1 def process_orientation(view_class, json_element)
  156. 8 attrs = {}
  157. 8 if view_class == 'LinearLayout' && json_element['orientation']
  158. 1 attrs['android:orientation'] = json_element['orientation']
  159. 7 elsif view_class == 'LinearLayout'
  160. # Default to vertical if not specified
  161. 1 attrs['android:orientation'] = 'vertical'
  162. end
  163. 8 attrs
  164. end
  165. end
  166. end

lib/xml/helpers/mappers/dimension_mapper.rb

100.0% lines covered

22 relevant lines. 22 lines covered and 0 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 module XmlGenerator
  3. 1 module Mappers
  4. 1 class DimensionMapper
  5. 1 def map_dimension(value)
  6. # Handle nil or empty string
  7. 47 return 'wrap_content' if value.nil? || value.to_s.empty?
  8. 45 case value
  9. when 'matchParent', 'match_parent'
  10. 13 'match_parent'
  11. when 'wrapContent', 'wrap_content'
  12. 16 'wrap_content'
  13. when Integer, Float
  14. 7 "#{value.to_i}dp"
  15. when /^\d+$/
  16. 1 "#{value}dp"
  17. when /^\d+\.\d+$/
  18. 1 "#{value.to_f.to_i}dp"
  19. when /^\d+dp$/
  20. 3 value
  21. when /^\d+%$/
  22. 1 "0dp" # Will need layout_weight
  23. else
  24. 3 value.to_s.empty? ? 'wrap_content' : value.to_s
  25. end
  26. end
  27. 1 def convert_dimension(value)
  28. 24 case value
  29. when Integer, Float
  30. 19 "#{value.to_i}dp"
  31. when String
  32. 2 if value.match?(/^\d+$/)
  33. 1 "#{value}dp"
  34. else
  35. 1 value
  36. end
  37. when Array
  38. # Use first value for now
  39. 2 convert_dimension(value.first || 0)
  40. else
  41. 1 value.to_s
  42. end
  43. end
  44. end
  45. end
  46. end

lib/xml/helpers/mappers/input_mapper.rb

95.24% lines covered

42 relevant lines. 40 lines covered and 2 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 module XmlGenerator
  3. 1 module Mappers
  4. 1 class InputMapper
  5. 1 def map_input_attributes(key, value)
  6. 45 case key
  7. # Input attributes
  8. when 'inputType'
  9. 6 return { namespace: 'android', name: 'inputType', value: map_input_type(value) }
  10. when 'placeholder'
  11. 1 return { namespace: 'android', name: 'hint', value: value }
  12. when 'editable'
  13. 1 return { namespace: 'android', name: 'editable', value: value.to_s }
  14. when 'singleLine'
  15. 1 return { namespace: 'android', name: 'singleLine', value: value.to_s }
  16. when 'maxLength'
  17. 1 return { namespace: 'android', name: 'maxLength', value: value.to_s }
  18. # Switch/Checkbox
  19. when 'checked', 'isChecked'
  20. 3 return { namespace: 'android', name: 'checked', value: process_checked_value(value) }
  21. # SelectBox/Spinner
  22. when 'selectedItem'
  23. 1 return { namespace: 'app', name: 'selectedValue', value: value }
  24. when 'entries', 'items'
  25. 2 if value.is_a?(Array)
  26. 2 return { namespace: 'app', name: 'items', value: value.join('|') }
  27. else
  28. return { namespace: 'app', name: 'items', value: value }
  29. end
  30. when 'selectItemType'
  31. return { namespace: 'tools', name: 'selectItemType', value: value }
  32. when 'hintColor'
  33. # Process color value through ResourceResolver
  34. 1 color_value = KjuiTools::Xml::Helpers::ResourceResolver.process_color(value)
  35. 1 return { namespace: 'app', name: 'hintColor', value: color_value }
  36. when 'prompt'
  37. 1 return { namespace: 'app', name: 'placeholder', value: value }
  38. # Date picker attributes
  39. when 'datePickerMode', 'datePickerStyle'
  40. 1 return { namespace: 'app', name: 'datePickerMode', value: value }
  41. when 'dateFormat'
  42. 1 return { namespace: 'app', name: 'dateFormat', value: value }
  43. when 'minDate', 'minimumDate'
  44. 1 return { namespace: 'app', name: 'minDate', value: value }
  45. when 'maxDate', 'maximumDate'
  46. 1 return { namespace: 'app', name: 'maxDate', value: value }
  47. # Progress/Slider
  48. when 'progress'
  49. 1 return { namespace: 'android', name: 'progress', value: value.to_s }
  50. when 'max', 'maxValue', 'maximumValue'
  51. 1 return { namespace: 'android', name: 'max', value: value.to_f.to_i.to_s }
  52. when 'min', 'minValue', 'minimumValue'
  53. 1 return { namespace: 'android', name: 'min', value: value.to_f.to_i.to_s }
  54. when 'value'
  55. # For Slider, value maps to progress
  56. 2 return { namespace: 'android', name: 'progress', value: process_binding_value(value) }
  57. when 'onValueChange'
  58. 1 return nil # Handled in code generation
  59. # Events (will be handled in binding)
  60. when 'onClick', 'onclick'
  61. 1 return { namespace: 'android', name: 'onClick', value: value }
  62. when 'onTextChanged'
  63. 1 return nil # Handled in code
  64. end
  65. nil
  66. end
  67. 1 private
  68. 1 def map_input_type(value)
  69. input_type_map = {
  70. 6 'text' => 'text',
  71. 'number' => 'number',
  72. 'phone' => 'phone',
  73. 'email' => 'textEmailAddress',
  74. 'password' => 'textPassword',
  75. 'multiline' => 'textMultiLine'
  76. }
  77. 6 input_type_map[value] || value
  78. end
  79. 1 def process_checked_value(value)
  80. 3 if value.is_a?(String) && value.start_with?('@{')
  81. 1 value
  82. else
  83. 2 value.to_s
  84. end
  85. end
  86. 1 def process_binding_value(value)
  87. 2 if value.is_a?(String) && value.start_with?('@{')
  88. 1 value
  89. else
  90. 1 value.to_s
  91. end
  92. end
  93. end
  94. end
  95. end

lib/xml/helpers/mappers/layout_mapper.rb

62.96% lines covered

108 relevant lines. 68 lines covered and 40 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 module XmlGenerator
  3. 1 module Mappers
  4. 1 class LayoutMapper
  5. 1 def initialize(dimension_mapper)
  6. 105 @dimension_mapper = dimension_mapper
  7. end
  8. 1 def map_layout_attributes(key, value, component_type, parent_type)
  9. 44 case key
  10. # Dimension attributes
  11. when 'width'
  12. 2 return { namespace: 'android', name: 'layout_width', value: @dimension_mapper.map_dimension(value) }
  13. when 'height'
  14. 2 return { namespace: 'android', name: 'layout_height', value: @dimension_mapper.map_dimension(value) }
  15. # Padding attributes
  16. when 'padding', 'paddings'
  17. 4 if value.is_a?(Array)
  18. 1 return { namespace: 'android', name: 'padding', value: @dimension_mapper.convert_dimension(value.first || 0) }
  19. else
  20. 3 return { namespace: 'android', name: 'padding', value: @dimension_mapper.convert_dimension(value) }
  21. end
  22. when 'topPadding', 'paddingTop'
  23. 1 return { namespace: 'android', name: 'paddingTop', value: @dimension_mapper.convert_dimension(value) }
  24. when 'bottomPadding', 'paddingBottom'
  25. 1 return { namespace: 'android', name: 'paddingBottom', value: @dimension_mapper.convert_dimension(value) }
  26. when 'leftPadding', 'paddingLeft', 'startPadding', 'paddingStart'
  27. 1 return { namespace: 'android', name: 'paddingStart', value: @dimension_mapper.convert_dimension(value) }
  28. when 'rightPadding', 'paddingRight', 'endPadding', 'paddingEnd'
  29. 1 return { namespace: 'android', name: 'paddingEnd', value: @dimension_mapper.convert_dimension(value) }
  30. # Margin attributes
  31. when 'margin'
  32. 2 if value.is_a?(Array)
  33. 1 return { namespace: 'android', name: 'layout_margin', value: @dimension_mapper.convert_dimension(value.first || 0) }
  34. else
  35. 1 return { namespace: 'android', name: 'layout_margin', value: @dimension_mapper.convert_dimension(value) }
  36. end
  37. when 'topMargin', 'marginTop'
  38. 1 return { namespace: 'android', name: 'layout_marginTop', value: @dimension_mapper.convert_dimension(value) }
  39. when 'bottomMargin', 'marginBottom'
  40. 1 return { namespace: 'android', name: 'layout_marginBottom', value: @dimension_mapper.convert_dimension(value) }
  41. when 'leftMargin', 'marginLeft', 'startMargin', 'marginStart'
  42. 1 return { namespace: 'android', name: 'layout_marginStart', value: @dimension_mapper.convert_dimension(value) }
  43. when 'rightMargin', 'marginRight', 'endMargin', 'marginEnd'
  44. 1 return { namespace: 'android', name: 'layout_marginEnd', value: @dimension_mapper.convert_dimension(value) }
  45. # Layout specific
  46. when 'orientation'
  47. 1 return { namespace: 'android', name: 'orientation', value: value }
  48. when 'weight'
  49. 1 return { namespace: 'android', name: 'layout_weight', value: value.to_s }
  50. when 'gravity'
  51. 2 return { namespace: 'android', name: 'gravity', value: map_gravity(value) }
  52. when 'layout_gravity'
  53. 1 return { namespace: 'android', name: 'layout_gravity', value: map_gravity(value) }
  54. end
  55. nil
  56. end
  57. 1 def map_alignment_attributes(key, value, parent_type)
  58. # Check if parent is ConstraintLayout
  59. 38 is_constraint_layout = parent_type&.include?('ConstraintLayout')
  60. 38 case key
  61. when 'alignTop'
  62. 3 if parent_type == 'LinearLayout'
  63. 1 return { namespace: 'android', name: 'layout_gravity', value: 'top' } if value
  64. 2 elsif is_constraint_layout
  65. 1 return { namespace: 'app', name: 'layout_constraintTop_toTopOf', value: 'parent' } if value
  66. else
  67. 1 return { namespace: 'android', name: 'layout_alignParentTop', value: value.to_s }
  68. end
  69. when 'alignBottom'
  70. 3 if parent_type == 'LinearLayout'
  71. 1 return { namespace: 'android', name: 'layout_gravity', value: 'bottom' } if value
  72. 2 elsif is_constraint_layout
  73. 2 return { namespace: 'app', name: 'layout_constraintBottom_toBottomOf', value: 'parent' } if value
  74. else
  75. return { namespace: 'android', name: 'layout_alignParentBottom', value: value.to_s }
  76. end
  77. when 'alignLeft', 'alignStart'
  78. 2 if parent_type == 'LinearLayout'
  79. 1 return { namespace: 'android', name: 'layout_gravity', value: 'start' } if value
  80. 1 elsif is_constraint_layout
  81. 1 return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: 'parent' } if value
  82. else
  83. return { namespace: 'android', name: 'layout_alignParentStart', value: value.to_s }
  84. end
  85. when 'alignRight', 'alignEnd'
  86. 2 if parent_type == 'LinearLayout'
  87. return { namespace: 'android', name: 'layout_gravity', value: 'end' } if value
  88. 2 elsif is_constraint_layout
  89. 2 return { namespace: 'app', name: 'layout_constraintEnd_toEndOf', value: 'parent' } if value
  90. else
  91. return { namespace: 'android', name: 'layout_alignParentEnd', value: value.to_s }
  92. end
  93. when 'centerHorizontal'
  94. 2 if parent_type == 'LinearLayout'
  95. 1 return { namespace: 'android', name: 'layout_gravity', value: 'center_horizontal' } if value
  96. 1 elsif is_constraint_layout
  97. # For horizontal centering in ConstraintLayout, we need both start and end constraints
  98. # This will be handled specially
  99. return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: 'parent', extra: 'center_horizontal' } if value
  100. else
  101. 1 return { namespace: 'android', name: 'layout_centerHorizontal', value: value.to_s }
  102. end
  103. when 'centerVertical'
  104. if parent_type == 'LinearLayout'
  105. return { namespace: 'android', name: 'layout_gravity', value: 'center_vertical' } if value
  106. elsif is_constraint_layout
  107. # For vertical centering in ConstraintLayout, we need both top and bottom constraints
  108. return { namespace: 'app', name: 'layout_constraintTop_toTopOf', value: 'parent', extra: 'center_vertical' } if value
  109. else
  110. return { namespace: 'android', name: 'layout_centerVertical', value: value.to_s }
  111. end
  112. when 'centerInParent'
  113. 1 if parent_type == 'LinearLayout'
  114. 1 return { namespace: 'android', name: 'layout_gravity', value: 'center' } if value
  115. elsif is_constraint_layout
  116. # For centering in ConstraintLayout, we need all four constraints
  117. return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: 'parent', extra: 'center_in_parent' } if value
  118. else
  119. return { namespace: 'android', name: 'layout_centerInParent', value: value.to_s }
  120. end
  121. # Relative positioning - align to edges of another view
  122. when 'alignTopView'
  123. if is_constraint_layout
  124. return { namespace: 'app', name: 'layout_constraintTop_toTopOf', value: "@id/#{value}" }
  125. else
  126. return { namespace: 'android', name: 'layout_alignTop', value: "@id/#{value}" }
  127. end
  128. when 'alignBottomView'
  129. if is_constraint_layout
  130. return { namespace: 'app', name: 'layout_constraintBottom_toBottomOf', value: "@id/#{value}" }
  131. else
  132. return { namespace: 'android', name: 'layout_alignBottom', value: "@id/#{value}" }
  133. end
  134. when 'alignLeftView'
  135. if is_constraint_layout
  136. return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: "@id/#{value}" }
  137. else
  138. return { namespace: 'android', name: 'layout_alignStart', value: "@id/#{value}" }
  139. end
  140. when 'alignRightView'
  141. if is_constraint_layout
  142. return { namespace: 'app', name: 'layout_constraintEnd_toEndOf', value: "@id/#{value}" }
  143. else
  144. return { namespace: 'android', name: 'layout_alignEnd', value: "@id/#{value}" }
  145. end
  146. # Center alignment with another view (ConstraintLayout only)
  147. when 'alignCenterVerticalView'
  148. if is_constraint_layout
  149. # To center vertically with another view, constrain both top and bottom to that view
  150. return { namespace: 'app', name: 'layout_constraintTop_toTopOf', value: "@id/#{value}", extra: 'center_vertical_to_view' }
  151. else
  152. puts "Warning: alignCenterVerticalView requires ConstraintLayout"
  153. return nil
  154. end
  155. when 'alignCenterHorizontalView'
  156. if is_constraint_layout
  157. # To center horizontally with another view, constrain both start and end to that view
  158. return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: "@id/#{value}", extra: 'center_horizontal_to_view' }
  159. else
  160. puts "Warning: alignCenterHorizontalView requires ConstraintLayout"
  161. return nil
  162. end
  163. # Position relative to another view (outside edges)
  164. when 'alignTopOfView', 'above'
  165. 2 if is_constraint_layout
  166. 1 return { namespace: 'app', name: 'layout_constraintBottom_toTopOf', value: "@id/#{value}" }
  167. else
  168. 1 return { namespace: 'android', name: 'layout_above', value: "@id/#{value}" }
  169. end
  170. when 'alignBottomOfView', 'below'
  171. 2 if is_constraint_layout
  172. 1 return { namespace: 'app', name: 'layout_constraintTop_toBottomOf', value: "@id/#{value}" }
  173. else
  174. 1 return { namespace: 'android', name: 'layout_below', value: "@id/#{value}" }
  175. end
  176. when 'alignLeftOfView', 'toLeftOf'
  177. 1 if is_constraint_layout
  178. return { namespace: 'app', name: 'layout_constraintEnd_toStartOf', value: "@id/#{value}" }
  179. else
  180. 1 return { namespace: 'android', name: 'layout_toStartOf', value: "@id/#{value}" }
  181. end
  182. when 'alignRightOfView', 'toRightOf'
  183. 1 if is_constraint_layout
  184. return { namespace: 'app', name: 'layout_constraintStart_toEndOf', value: "@id/#{value}" }
  185. else
  186. 1 return { namespace: 'android', name: 'layout_toEndOf', value: "@id/#{value}" }
  187. end
  188. end
  189. nil
  190. end
  191. 1 private
  192. 1 def map_gravity(value)
  193. 3 if value.is_a?(Array)
  194. 1 value.join('|')
  195. else
  196. 2 case value
  197. when 'center'
  198. 2 'center'
  199. when 'left', 'start'
  200. 'start'
  201. when 'right', 'end'
  202. 'end'
  203. when 'top'
  204. 'top'
  205. when 'bottom'
  206. 'bottom'
  207. else
  208. value
  209. end
  210. end
  211. end
  212. end
  213. end
  214. end

lib/xml/helpers/mappers/style_mapper.rb

65.55% lines covered

119 relevant lines. 78 lines covered and 41 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require_relative '../resource_resolver'
  3. 1 module XmlGenerator
  4. 1 module Mappers
  5. 1 class StyleMapper
  6. 1 def initialize(text_mapper, drawable_generator = nil)
  7. 103 @text_mapper = text_mapper
  8. 103 @drawable_generator = drawable_generator
  9. end
  10. 1 def map_style_attributes(key, value, json_element = nil, component_type = nil)
  11. 54 case key
  12. # Background and appearance
  13. when 'background', 'backgroundColor'
  14. # Check if we need to generate a drawable
  15. 3 if @drawable_generator && json_element && needs_drawable?(json_element, component_type)
  16. drawable_name = @drawable_generator.get_background_drawable(json_element, component_type)
  17. if drawable_name
  18. return { namespace: 'android', name: 'background', value: "@drawable/#{drawable_name}" }
  19. end
  20. end
  21. 3 return { namespace: 'android', name: 'background', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  22. when 'cornerRadius'
  23. # Handled in drawable generation
  24. return nil if @drawable_generator
  25. return { namespace: 'tools', name: 'cornerRadius', value: convert_dimension(value) }
  26. when 'borderWidth', 'strokeWidth'
  27. # Handled in drawable generation
  28. return nil if @drawable_generator
  29. return { namespace: 'tools', name: 'strokeWidth', value: convert_dimension(value) }
  30. when 'borderColor', 'strokeColor'
  31. # Handled in drawable generation
  32. return nil if @drawable_generator
  33. return { namespace: 'tools', name: 'strokeColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  34. when 'borderStyle'
  35. # Handled in drawable generation if available
  36. return nil if @drawable_generator
  37. return { namespace: 'tools', name: 'borderStyle', value: value }
  38. when 'opacity', 'alpha'
  39. 2 return { namespace: 'android', name: 'alpha', value: value.to_f.to_s }
  40. when 'visibility'
  41. 3 return { namespace: 'android', name: 'visibility', value: map_visibility(value) }
  42. when 'enabled'
  43. 1 return { namespace: 'android', name: 'enabled', value: value.to_s }
  44. when 'clickable'
  45. 1 return { namespace: 'android', name: 'clickable', value: value.to_s }
  46. when 'focusable'
  47. 1 return { namespace: 'android', name: 'focusable', value: value.to_s }
  48. # Image attributes
  49. when 'src', 'source', 'image'
  50. 3 return map_image_source(value, component_type)
  51. when 'url'
  52. # For NetworkImageView and CircleImageView
  53. 1 return { namespace: 'app', name: 'url', value: value }
  54. when 'placeholderImage'
  55. # For NetworkImageView placeholder image
  56. 1 if value.start_with?('@drawable/')
  57. return { namespace: 'app', name: 'placeholderImage', value: value }
  58. else
  59. 1 resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
  60. 1 return { namespace: 'app', name: 'placeholderImage', value: "@drawable/#{resource_name}" }
  61. end
  62. when 'placeholder'
  63. # For NetworkImageView/CircleImageView, use placeholderImage
  64. 1 if component_type && ['NetworkImage', 'CircleImage'].include?(component_type)
  65. 1 if value.start_with?('@drawable/')
  66. return { namespace: 'app', name: 'placeholderImage', value: value }
  67. else
  68. 1 resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
  69. 1 return { namespace: 'app', name: 'placeholderImage', value: "@drawable/#{resource_name}" }
  70. end
  71. end
  72. # For other components, let input_mapper handle it as hint
  73. return nil
  74. when 'errorImage', 'failureImage'
  75. # For NetworkImageView error image
  76. 1 if value.start_with?('@drawable/')
  77. return { namespace: 'app', name: 'errorImage', value: value }
  78. else
  79. 1 resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
  80. 1 return { namespace: 'app', name: 'errorImage', value: "@drawable/#{resource_name}" }
  81. end
  82. when 'defaultImage', 'fallbackImage'
  83. # For NetworkImageView default/fallback image
  84. if value.start_with?('@drawable/')
  85. return { namespace: 'app', name: 'defaultImage', value: value }
  86. else
  87. resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
  88. return { namespace: 'app', name: 'defaultImage', value: "@drawable/#{resource_name}" }
  89. end
  90. when 'crossfadeEnabled', 'crossfade'
  91. 1 return { namespace: 'app', name: 'crossfadeEnabled', value: value.to_s }
  92. when 'cacheEnabled'
  93. 1 return { namespace: 'app', name: 'cacheEnabled', value: value.to_s }
  94. when 'scaleType'
  95. 2 return { namespace: 'android', name: 'scaleType', value: map_scale_type(value) }
  96. when 'tint'
  97. 1 return { namespace: 'android', name: 'tint', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  98. # Blur attributes
  99. when 'blurRadius'
  100. 1 return { namespace: 'app', name: 'blurRadius', value: value.to_f.to_s }
  101. when 'blurOverlayColor'
  102. 1 return { namespace: 'app', name: 'blurOverlayColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  103. when 'downsampleFactor'
  104. 1 return { namespace: 'app', name: 'downsampleFactor', value: value.to_f.to_s }
  105. when 'blurEnabled'
  106. 1 return { namespace: 'app', name: 'blurEnabled', value: value.to_s }
  107. # Gradient attributes
  108. when 'gradientStartColor', 'startColor'
  109. 1 return { namespace: 'app', name: 'gradientStartColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  110. when 'gradientEndColor', 'endColor'
  111. 1 return { namespace: 'app', name: 'gradientEndColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  112. when 'gradientCenterColor', 'centerColor'
  113. return { namespace: 'app', name: 'gradientCenterColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
  114. when 'gradientColors', 'colors'
  115. # Handle array of colors - don't process through ResourceResolver
  116. # gradientColors expects raw color values separated by |
  117. 1 if value.is_a?(Array)
  118. 4 colors_string = value.map { |c| normalize_color_for_gradient(c) }.join('|')
  119. 1 return { namespace: 'app', name: 'gradientColors', value: colors_string }
  120. else
  121. return { namespace: 'app', name: 'gradientColors', value: value }
  122. end
  123. when 'gradientDirection', 'direction'
  124. 2 return { namespace: 'app', name: 'gradientOrientation', value: map_gradient_direction(value) }
  125. when 'gradientAngle', 'angle'
  126. 1 return { namespace: 'app', name: 'gradientAngle', value: value.to_s }
  127. when 'gradientType'
  128. 2 return { namespace: 'app', name: 'gradientType', value: map_gradient_type(value) }
  129. when 'gradientRadius'
  130. 1 return { namespace: 'app', name: 'gradientRadius', value: value.to_f.to_s }
  131. when 'gradientCenterX'
  132. return { namespace: 'app', name: 'gradientCenterX', value: value.to_f.to_s }
  133. when 'gradientCenterY'
  134. return { namespace: 'app', name: 'gradientCenterY', value: value.to_f.to_s }
  135. # SafeAreaView attributes
  136. when 'safeAreaInsetPositions', 'insetPositions'
  137. # Handle array of positions
  138. 1 if value.is_a?(Array)
  139. 1 positions_string = value.join('|')
  140. 1 return { namespace: 'app', name: 'safeAreaInsetPositions', value: positions_string }
  141. else
  142. return { namespace: 'app', name: 'safeAreaInsetPositions', value: value }
  143. end
  144. when 'contentInsetAdjustmentBehavior'
  145. return { namespace: 'app', name: 'contentInsetAdjustmentBehavior', value: value.to_s }
  146. when 'applyTopInset'
  147. 1 return { namespace: 'app', name: 'applyTopInset', value: value.to_s }
  148. when 'applyBottomInset'
  149. 1 return { namespace: 'app', name: 'applyBottomInset', value: value.to_s }
  150. when 'applyLeftInset'
  151. return { namespace: 'app', name: 'applyLeftInset', value: value.to_s }
  152. when 'applyRightInset'
  153. return { namespace: 'app', name: 'applyRightInset', value: value.to_s }
  154. when 'applyStartInset'
  155. return { namespace: 'app', name: 'applyStartInset', value: value.to_s }
  156. when 'applyEndInset'
  157. return { namespace: 'app', name: 'applyEndInset', value: value.to_s }
  158. # State-specific attributes (handled by drawable generation)
  159. when 'disabledBackground', 'tapBackground', 'pressedBackground',
  160. 'selectedBackground', 'focusedBackground', 'checkedBackground',
  161. 'rippleColor', 'rippleBorderless'
  162. # These are handled by drawable generation
  163. return nil if @drawable_generator
  164. return { namespace: 'tools', name: key, value: value.to_s }
  165. end
  166. nil
  167. end
  168. 1 private
  169. 1 def needs_drawable?(json_element, component_type)
  170. return false unless json_element
  171. # Check if any drawable-related attributes exist
  172. json_element['cornerRadius'] ||
  173. json_element['borderWidth'] ||
  174. json_element['borderColor'] ||
  175. json_element['gradient'] ||
  176. json_element['disabledBackground'] ||
  177. json_element['tapBackground'] ||
  178. json_element['pressedBackground'] ||
  179. json_element['selectedBackground'] ||
  180. json_element['focusedBackground'] ||
  181. json_element['checkedBackground'] ||
  182. json_element['onClick'] ||
  183. json_element['onclick'] ||
  184. json_element['rippleColor'] ||
  185. ['Button', 'ImageButton', 'Card', 'ListItem'].include?(component_type)
  186. end
  187. 1 def convert_dimension(value)
  188. case value
  189. when Integer, Float
  190. "#{value.to_i}dp"
  191. when String
  192. if value.match?(/^\d+$/)
  193. "#{value}dp"
  194. else
  195. value
  196. end
  197. else
  198. value.to_s
  199. end
  200. end
  201. 1 def map_visibility(value)
  202. 3 case value
  203. when true, 'visible'
  204. 1 'visible'
  205. when false, 'gone'
  206. 1 'gone'
  207. when 'invisible'
  208. 1 'invisible'
  209. else
  210. value
  211. end
  212. end
  213. 1 def map_image_source(value, component_type = nil)
  214. # For NetworkImageView and CircleImageView, map src to url attribute
  215. 3 if component_type && ['NetworkImage', 'CircleImage'].include?(component_type)
  216. 1 return { namespace: 'app', name: 'url', value: value }
  217. end
  218. 2 if value.start_with?('http')
  219. # Network image - use tools for documentation
  220. 1 { namespace: 'tools', name: 'src', value: value }
  221. else
  222. # Local resource
  223. 1 resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
  224. 1 { namespace: 'android', name: 'src', value: "@drawable/#{resource_name}" }
  225. end
  226. end
  227. 1 def map_scale_type(value)
  228. scale_type_map = {
  229. 2 'fill' => 'centerCrop',
  230. 'fit' => 'fitCenter',
  231. 'stretch' => 'fitXY',
  232. 'center' => 'center'
  233. }
  234. 2 scale_type_map[value] || value
  235. end
  236. 1 def map_gradient_direction(value)
  237. direction_map = {
  238. 2 'vertical' => 'top_bottom',
  239. 'horizontal' => 'left_right',
  240. 'diagonal' => 'tl_br',
  241. 'diagonal_reverse' => 'tr_bl',
  242. 'topBottom' => 'top_bottom',
  243. 'bottomTop' => 'bottom_top',
  244. 'leftRight' => 'left_right',
  245. 'rightLeft' => 'right_left',
  246. 'rightToLeft' => 'right_left',
  247. 'leftToRight' => 'left_right',
  248. 'topToBottom' => 'top_bottom',
  249. 'bottomToTop' => 'bottom_top',
  250. 'tlBr' => 'tl_br',
  251. 'trBl' => 'tr_bl',
  252. 'blTr' => 'bl_tr',
  253. 'brTl' => 'br_tl'
  254. }
  255. 2 direction_map[value] || 'top_bottom' # Default to top_bottom for unknown values
  256. end
  257. 1 def map_gradient_type(value)
  258. type_map = {
  259. 2 'linear' => 'linear',
  260. 'radial' => 'radial',
  261. 'sweep' => 'sweep',
  262. 'angular' => 'sweep'
  263. }
  264. 2 type_map[value] || 'linear'
  265. end
  266. 1 def normalize_color_for_gradient(color)
  267. 3 return '#00000000' if color == 'clear' || color == 'transparent'
  268. # Ensure hex format for colors
  269. 3 if color.match?(/^#?[A-Fa-f0-9]{6,8}$/)
  270. 3 color.start_with?('#') ? color : "##{color}"
  271. else
  272. # Return as-is for named colors or other formats
  273. color
  274. end
  275. end
  276. end
  277. end
  278. end

lib/xml/helpers/mappers/text_mapper.rb

93.06% lines covered

72 relevant lines. 67 lines covered and 5 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require_relative '../resource_resolver'
  3. 1 module XmlGenerator
  4. 1 module Mappers
  5. 1 class TextMapper
  6. 1 def initialize(string_resource_manager = nil)
  7. 141 @string_resource_manager = string_resource_manager
  8. end
  9. 1 def map_text_attributes(key, value, component_type)
  10. 54 case key
  11. when 'text'
  12. 5 return { namespace: 'android', name: 'text', value: process_text_value(value) }
  13. when 'hint'
  14. 2 hint_value = process_hint_value(value)
  15. 2 return { namespace: 'android', name: 'hint', value: hint_value }
  16. when 'fontSize', 'textSize'
  17. 4 return { namespace: 'android', name: 'textSize', value: convert_text_size(value) }
  18. when 'fontColor', 'textColor'
  19. 2 return { namespace: 'android', name: 'textColor', value: convert_color(value) }
  20. when 'color'
  21. # Generic color attribute - determine based on component type
  22. 3 if ['Label', 'Text', 'TextView', 'Button'].include?(component_type)
  23. 2 return { namespace: 'android', name: 'textColor', value: convert_color(value) }
  24. else
  25. 1 return { namespace: 'android', name: 'tint', value: convert_color(value) }
  26. end
  27. when 'font'
  28. # Check if it's a font weight/style or a font file name
  29. 7 if ['bold', 'italic', 'normal', 'bold_italic'].include?(value.to_s.downcase)
  30. # It's a text style
  31. 2 return map_font_weight(value)
  32. 5 elsif ['Label', 'Text', 'TextView', 'TextField', 'SecureField', 'Button'].include?(component_type)
  33. # It's a font file name for Kjui views
  34. # Add .ttf extension if not present
  35. 4 font_file = value.to_s
  36. 4 font_file += '.ttf' unless font_file.end_with?('.ttf', '.otf')
  37. 4 return { namespace: 'app', name: 'kjui_font_name', value: font_file }
  38. else
  39. # For non-Kjui views, use as fontFamily
  40. 1 return { namespace: 'android', name: 'fontFamily', value: value }
  41. end
  42. when 'fontFamily'
  43. # fontFamily is always treated as a font file name
  44. 2 if ['Label', 'Text', 'TextView', 'TextField', 'SecureField', 'Button'].include?(component_type)
  45. 1 font_file = value.to_s
  46. 1 font_file += '.ttf' unless font_file.end_with?('.ttf', '.otf')
  47. 1 return { namespace: 'app', name: 'kjui_font_name', value: font_file }
  48. else
  49. 1 return { namespace: 'android', name: 'fontFamily', value: value }
  50. end
  51. when 'fontWeight'
  52. 6 return map_font_weight(value)
  53. when 'fontStyle'
  54. return { namespace: 'android', name: 'textStyle', value: value }
  55. when 'textAlign', 'textAlignment'
  56. 5 return { namespace: 'android', name: 'textAlignment', value: map_text_alignment(value) }
  57. when 'maxLines'
  58. 1 return { namespace: 'android', name: 'maxLines', value: value.to_s }
  59. when 'ellipsize'
  60. 1 return { namespace: 'android', name: 'ellipsize', value: value }
  61. end
  62. nil
  63. end
  64. 1 private
  65. 1 def process_hint_value(value)
  66. # Handle data binding
  67. 2 if value.is_a?(String) && value.start_with?('@{')
  68. 1 return value
  69. end
  70. # Convert value to string
  71. 1 text = value.to_s
  72. # Use ResourceResolver to check for string resources
  73. 1 KjuiTools::Xml::Helpers::ResourceResolver.process_text(text)
  74. end
  75. 1 def process_text_value(value)
  76. # Handle data binding
  77. 5 if value.is_a?(String) && value.start_with?('@{')
  78. 1 return value
  79. end
  80. # Convert value to string
  81. 4 text = value.to_s
  82. # Use ResourceResolver to check for string resources
  83. 4 KjuiTools::Xml::Helpers::ResourceResolver.process_text(text)
  84. end
  85. 1 def convert_text_size(value)
  86. 4 case value
  87. when Integer, Float
  88. 2 "#{value}sp"
  89. when String
  90. 2 if value.match?(/^\d+$/)
  91. 1 "#{value}sp"
  92. else
  93. 1 value
  94. end
  95. else
  96. "14sp"
  97. end
  98. end
  99. 1 def convert_color(value)
  100. 5 return nil if value.nil?
  101. # Handle special color values
  102. 5 if value.is_a?(String)
  103. 5 if value == 'clear' || value == 'transparent'
  104. return '#00000000'
  105. end
  106. # Use ResourceResolver to check for color resources
  107. 5 return KjuiTools::Xml::Helpers::ResourceResolver.process_color(value)
  108. else
  109. value.to_s
  110. end
  111. end
  112. 1 def map_font_weight(value)
  113. 8 case value.to_s.downcase
  114. when 'bold'
  115. 2 { namespace: 'android', name: 'textStyle', value: 'bold' }
  116. when 'italic'
  117. 2 { namespace: 'android', name: 'textStyle', value: 'italic' }
  118. when 'bold_italic', 'bolditalic'
  119. 1 { namespace: 'android', name: 'textStyle', value: 'bold|italic' }
  120. when 'normal', 'regular', 'light', 'thin'
  121. 1 { namespace: 'android', name: 'textStyle', value: 'normal' }
  122. when 'medium', 'semibold', 'heavy', 'black'
  123. # Medium and similar weights map to bold in Android
  124. 1 { namespace: 'android', name: 'textStyle', value: 'bold' }
  125. else
  126. # Default to normal for unknown values
  127. 1 { namespace: 'android', name: 'textStyle', value: 'normal' }
  128. end
  129. end
  130. 1 def map_text_alignment(value)
  131. 5 case value
  132. when 'left', 'start'
  133. 2 'textStart'
  134. when 'right', 'end'
  135. 2 'textEnd'
  136. when 'center'
  137. 1 'center'
  138. else
  139. value
  140. end
  141. end
  142. end
  143. end
  144. end

lib/xml/helpers/resource_resolver.rb

72.16% lines covered

97 relevant lines. 70 lines covered and 27 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'json'
  3. 1 require_relative '../../core/logger'
  4. 1 module KjuiTools
  5. 1 module Xml
  6. 1 module Helpers
  7. 1 class ResourceResolver
  8. 1 class << self
  9. # Load resources lazily
  10. 1 def strings_data
  11. 6 @strings_data ||= load_strings_data
  12. end
  13. 1 def colors_data
  14. 112 @colors_data ||= load_colors_data
  15. end
  16. 1 def defined_colors_data
  17. 56 @defined_colors_data ||= load_defined_colors_data
  18. end
  19. # Clear cache (useful when resources change)
  20. 1 def clear_cache
  21. 19 @strings_data = nil
  22. 19 @colors_data = nil
  23. 19 @defined_colors_data = nil
  24. end
  25. # Process text value - returns @string/key or original text
  26. 1 def process_text(text)
  27. 10 return text if text.nil? || text.empty?
  28. # Skip data binding expressions
  29. 8 return text if text.start_with?('@{') || text.start_with?('${')
  30. # Find string key
  31. 6 string_key = find_string_key(text)
  32. 6 if string_key
  33. "@string/#{string_key}"
  34. else
  35. # Return original text wrapped in quotes for XML
  36. 6 "\"#{text}\""
  37. end
  38. end
  39. # Process color value - returns @color/key or hex color
  40. 1 def process_color(color)
  41. 61 return color if color.nil? || color.empty?
  42. # Skip data binding expressions
  43. 59 return color if color.start_with?('@{') || color.start_with?('${')
  44. # Skip if already a resource reference
  45. 57 return color if color.start_with?('@')
  46. # Find color key
  47. 56 color_key = find_color_key(color)
  48. 56 if color_key
  49. "@color/#{color_key}"
  50. else
  51. # Return hex color with # prefix
  52. 56 if color.match?(/^#?[A-Fa-f0-9]{6,8}$/)
  53. 56 color.start_with?('#') ? color : "##{color}"
  54. else
  55. color
  56. end
  57. end
  58. end
  59. 1 private
  60. 1 def load_strings_data
  61. 3 strings_file = find_strings_json
  62. 3 return {} unless strings_file && File.exist?(strings_file)
  63. begin
  64. data = JSON.parse(File.read(strings_file))
  65. # Flatten the nested structure (file -> key -> value)
  66. flattened = {}
  67. data.each do |file_prefix, file_strings|
  68. next unless file_strings.is_a?(Hash)
  69. file_strings.each do |key, value|
  70. full_key = "#{file_prefix}_#{key}"
  71. flattened[full_key] = value
  72. end
  73. end
  74. flattened
  75. rescue JSON::ParserError => e
  76. Core::Logger.warn "Failed to parse strings.json: #{e.message}"
  77. {}
  78. end
  79. end
  80. 1 def load_colors_data
  81. 5 colors_file = find_colors_json
  82. 5 return {} unless colors_file && File.exist?(colors_file)
  83. begin
  84. JSON.parse(File.read(colors_file))
  85. rescue JSON::ParserError => e
  86. Core::Logger.warn "Failed to parse colors.json: #{e.message}"
  87. {}
  88. end
  89. end
  90. 1 def load_defined_colors_data
  91. 5 defined_colors_file = find_defined_colors_json
  92. 5 return {} unless defined_colors_file && File.exist?(defined_colors_file)
  93. begin
  94. JSON.parse(File.read(defined_colors_file))
  95. rescue JSON::ParserError => e
  96. Core::Logger.warn "Failed to parse defined_colors.json: #{e.message}"
  97. {}
  98. end
  99. end
  100. 1 def find_strings_json
  101. # Try common locations
  102. 3 paths = [
  103. 'src/main/assets/Layouts/Resources/strings.json',
  104. 'app/src/main/assets/Layouts/Resources/strings.json',
  105. 'sample-app/src/main/assets/Layouts/Resources/strings.json'
  106. ]
  107. 3 paths.each do |path|
  108. 9 full_path = File.expand_path(path)
  109. 9 return full_path if File.exist?(full_path)
  110. end
  111. nil
  112. end
  113. 1 def find_colors_json
  114. # Try common locations
  115. 5 paths = [
  116. 'src/main/assets/Layouts/Resources/colors.json',
  117. 'app/src/main/assets/Layouts/Resources/colors.json',
  118. 'sample-app/src/main/assets/Layouts/Resources/colors.json'
  119. ]
  120. 5 paths.each do |path|
  121. 15 full_path = File.expand_path(path)
  122. 15 return full_path if File.exist?(full_path)
  123. end
  124. nil
  125. end
  126. 1 def find_defined_colors_json
  127. # Try common locations
  128. 5 paths = [
  129. 'src/main/assets/Layouts/Resources/defined_colors.json',
  130. 'app/src/main/assets/Layouts/Resources/defined_colors.json',
  131. 'sample-app/src/main/assets/Layouts/Resources/defined_colors.json'
  132. ]
  133. 5 paths.each do |path|
  134. 15 full_path = File.expand_path(path)
  135. 15 return full_path if File.exist?(full_path)
  136. end
  137. nil
  138. end
  139. 1 def find_string_key(text)
  140. 6 strings_data.find { |key, value| value == text }&.first
  141. end
  142. 1 def find_color_key(color)
  143. # If the color itself is a key in colors.json, return it
  144. 56 if colors_data.key?(color)
  145. return color
  146. end
  147. # If the color itself is in defined_colors.json, return it
  148. 56 if defined_colors_data.key?(color)
  149. return color
  150. end
  151. # Otherwise, normalize and search for hex values
  152. 56 normalized_color = normalize_color(color)
  153. # Check colors.json for hex values
  154. 56 if colors_data.any? && normalized_color
  155. found = colors_data.find { |key, value| normalize_color(value) == normalized_color }
  156. return found.first if found
  157. end
  158. nil
  159. end
  160. 1 def normalize_color(color)
  161. 61 return nil if color.nil?
  162. # If it's a hex color, normalize it
  163. 60 if color.match?(/^#?[A-Fa-f0-9]{6,8}$/)
  164. 58 hex = color.gsub('#', '').upcase
  165. # Convert 3-digit to 6-digit
  166. 58 if hex.length == 3
  167. hex = hex.chars.map { |c| c * 2 }.join
  168. end
  169. 58 "##{hex}"
  170. else
  171. 2 color
  172. end
  173. end
  174. end
  175. end
  176. end
  177. end
  178. end

lib/xml/resources/string_resource_manager.rb

37.8% lines covered

82 relevant lines. 31 lines covered and 51 lines missed.
    
  1. 1 require 'nokogiri'
  2. 1 require 'fileutils'
  3. 1 module XmlGenerator
  4. 1 module Resources
  5. 1 class StringResourceManager
  6. 1 def initialize(project_root)
  7. 24 @project_root = project_root
  8. 24 @strings_file_path = find_strings_file
  9. 24 @strings_cache = {}
  10. 24 @new_strings = {}
  11. 24 load_existing_strings
  12. end
  13. # Get or create a string resource reference
  14. 1 def get_string_resource(text)
  15. return nil if text.nil? || text.empty?
  16. # Check if it's already a resource reference
  17. return text if text.start_with?('@string/')
  18. # Check if it's a data binding expression
  19. return text if text.start_with?('@{')
  20. # Check if text is too short or just numbers
  21. return text if text.length < 2 || text.match?(/^\d+$/)
  22. # Check existing strings
  23. existing_name = find_existing_string(text)
  24. return "@string/#{existing_name}" if existing_name
  25. # Check if we already created this string in this session
  26. new_name = @new_strings.key(text)
  27. return "@string/#{new_name}" if new_name
  28. # Create new string resource
  29. string_name = generate_string_name(text)
  30. @new_strings[string_name] = text
  31. "@string/#{string_name}"
  32. end
  33. # Save all new strings to strings.xml
  34. 1 def save_new_strings
  35. 3 return if @new_strings.empty?
  36. ensure_strings_file_exists
  37. # Load the XML file
  38. doc = Nokogiri::XML(File.read(@strings_file_path)) do |config|
  39. config.default_xml.noblanks
  40. end
  41. resources = doc.at_xpath('//resources')
  42. # Add new strings
  43. @new_strings.each do |name, value|
  44. # Skip if already exists (double check)
  45. next if doc.at_xpath("//string[@name='#{name}']")
  46. # Create new string element
  47. string_element = Nokogiri::XML::Node.new('string', doc)
  48. string_element['name'] = name
  49. # Process the value to handle line breaks properly
  50. processed_value = escape_xml_text(value)
  51. # Replace line breaks with \n for XML
  52. processed_value = processed_value.gsub(/\r?\n/, '\n')
  53. string_element.content = processed_value
  54. # Add to resources
  55. resources.add_child("\n ")
  56. resources.add_child(string_element)
  57. end
  58. # Add final newline if there are children
  59. if resources.children.any?
  60. resources.add_child("\n")
  61. end
  62. # Save the file
  63. File.write(@strings_file_path, doc.to_xml(
  64. indent: 4,
  65. indent_text: ' ',
  66. save_with: Nokogiri::XML::Node::SaveOptions::FORMAT |
  67. Nokogiri::XML::Node::SaveOptions::AS_XML
  68. ))
  69. puts "✅ Added #{@new_strings.size} new strings to strings.xml"
  70. # Add to cache for future lookups
  71. @strings_cache.merge!(@new_strings)
  72. @new_strings.clear
  73. end
  74. 1 private
  75. 1 def find_strings_file
  76. possible_paths = [
  77. 24 File.join(@project_root, 'src', 'main', 'res', 'values', 'strings.xml'),
  78. File.join(@project_root, 'app', 'src', 'main', 'res', 'values', 'strings.xml'),
  79. File.join(@project_root, 'sample-app', 'src', 'main', 'res', 'values', 'strings.xml')
  80. ]
  81. 94 possible_paths.find { |path| File.exist?(path) } || possible_paths.first
  82. end
  83. 1 def ensure_strings_file_exists
  84. return if File.exist?(@strings_file_path)
  85. # Create directory if needed
  86. FileUtils.mkdir_p(File.dirname(@strings_file_path))
  87. # Create basic strings.xml
  88. content = <<~XML
  89. <?xml version="1.0" encoding="utf-8"?>
  90. <resources>
  91. <string name="app_name">App</string>
  92. </resources>
  93. XML
  94. File.write(@strings_file_path, content)
  95. end
  96. 1 def load_existing_strings
  97. 24 return unless File.exist?(@strings_file_path)
  98. begin
  99. 1 doc = Nokogiri::XML(File.read(@strings_file_path))
  100. # Load all existing strings into cache
  101. 1 doc.xpath('//string').each do |string_node|
  102. 1 name = string_node['name']
  103. 1 value = unescape_xml_text(string_node.text)
  104. 1 @strings_cache[name] = value if name && value
  105. end
  106. rescue => e
  107. puts "Warning: Could not parse strings.xml: #{e.message}"
  108. end
  109. end
  110. 1 def find_existing_string(text)
  111. # Exact match
  112. @strings_cache.find { |name, value| value == text }&.first
  113. end
  114. 1 def generate_string_name(text)
  115. # Generate a meaningful name from the text
  116. base_name = text.downcase
  117. .gsub(/[^a-z0-9\s_-]/, '') # Remove special characters
  118. .gsub(/-/, '_') # Replace hyphens with underscores
  119. .gsub(/\s+/, '_') # Replace spaces with underscores
  120. .gsub(/_+/, '_') # Remove duplicate underscores
  121. .gsub(/^_|_$/, '') # Remove leading/trailing underscores
  122. # Handle reserved words
  123. reserved_words = ['default', 'public', 'private', 'protected', 'static',
  124. 'final', 'abstract', 'class', 'interface', 'enum',
  125. 'package', 'import', 'return', 'if', 'else', 'switch',
  126. 'case', 'break', 'continue', 'for', 'while', 'do',
  127. 'try', 'catch', 'finally', 'throw', 'throws', 'new',
  128. 'this', 'super', 'extends', 'implements', 'void',
  129. 'boolean', 'int', 'long', 'float', 'double', 'char',
  130. 'byte', 'short', 'true', 'false', 'null']
  131. if reserved_words.include?(base_name)
  132. base_name = "str_#{base_name}"
  133. end
  134. # Limit length
  135. base_name = base_name[0..30] if base_name.length > 30
  136. # Ensure it starts with a letter
  137. base_name = "str_#{base_name}" unless base_name.match?(/^[a-z]/)
  138. # Handle empty or invalid names
  139. base_name = "str_text" if base_name.empty?
  140. # Make unique if needed
  141. final_name = base_name
  142. counter = 2
  143. while @strings_cache.key?(final_name) || @new_strings.key?(final_name)
  144. final_name = "#{base_name}_#{counter}"
  145. counter += 1
  146. end
  147. final_name
  148. end
  149. 1 def escape_xml_text(text)
  150. # Escape special characters for XML
  151. text.gsub('&', '&amp;')
  152. .gsub('<', '&lt;')
  153. .gsub('>', '&gt;')
  154. .gsub('"', '&quot;')
  155. .gsub("'", '&apos;')
  156. end
  157. 1 def unescape_xml_text(text)
  158. # Unescape XML entities
  159. 1 text.gsub('&amp;', '&')
  160. .gsub('&lt;', '<')
  161. .gsub('&gt;', '>')
  162. .gsub('&quot;', '"')
  163. .gsub('&apos;', "'")
  164. end
  165. end
  166. end
  167. end

lib/xml/xml_builder.rb

77.78% lines covered

126 relevant lines. 98 lines covered and 28 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require_relative '../core/config_manager'
  5. 1 require_relative '../core/project_finder'
  6. 1 require_relative '../core/attribute_validator'
  7. 1 require_relative 'xml_generator'
  8. 1 module KjuiTools
  9. 1 module Xml
  10. 1 class XmlBuilder
  11. 1 attr_accessor :validation_enabled, :validation_callback
  12. 1 def initialize(config = nil)
  13. 11 @config = config || Core::ConfigManager.load_config
  14. 11 Core::ProjectFinder.setup_paths
  15. # Use current directory as project path (where kjui.config.json is located)
  16. 11 @project_path = Dir.pwd
  17. 11 @layouts_dir = File.join(@project_path, @config['source_directory'] || 'src/main', @config['layouts_directory'] || 'assets/Layouts')
  18. 11 @output_dir = File.join(@project_path, @config['source_directory'] || 'src/main', 'res/layout')
  19. 11 @generated_count = 0
  20. 11 @failed_count = 0
  21. 11 @skipped_count = 0
  22. 11 @validation_enabled = false
  23. 11 @validation_callback = nil
  24. 11 @validator = nil
  25. end
  26. 1 def build(options = {})
  27. 7 puts "🔨 Building XML View files..."
  28. 7 puts "📁 Project: #{@project_path}"
  29. 7 puts "📂 Layouts: #{@layouts_dir}"
  30. 7 puts "📂 Output: #{@output_dir}"
  31. 7 puts "-" * 60
  32. 7 unless Dir.exist?(@layouts_dir)
  33. 1 puts "❌ Layouts directory not found: #{@layouts_dir}"
  34. 1 return false
  35. end
  36. # Clean output directory if requested
  37. 6 if options[:clean]
  38. 1 clean_output_directory
  39. end
  40. # Ensure output directory exists
  41. 6 FileUtils.mkdir_p(@output_dir)
  42. # Initialize validator if validation is enabled
  43. 6 @validator = Core::AttributeValidator.new(:xml) if @validation_enabled
  44. # Get all JSON files (excluding Resources folder)
  45. 6 json_files = Dir.glob(File.join(@layouts_dir, '*.json'))
  46. # Also get JSON files from subdirectories, but exclude Resources
  47. 6 json_files += Dir.glob(File.join(@layouts_dir, '**/*.json')).reject do |file|
  48. 4 file.include?('/Resources/')
  49. end
  50. 6 json_files.uniq!
  51. 6 if json_files.empty?
  52. 2 puts "⚠️ No JSON files found in #{@layouts_dir}"
  53. 2 return true
  54. end
  55. 4 puts "📄 Found #{json_files.length} JSON files"
  56. 4 puts "-" * 60
  57. # Extract resources before processing layouts
  58. 4 require_relative '../core/resources_manager'
  59. 4 resources_manager = Core::ResourcesManager.new(@config, @project_path)
  60. 4 resources_manager.extract_resources(json_files)
  61. 4 puts "-" * 60
  62. # Process each file
  63. 4 json_files.each do |json_file|
  64. 4 process_layout(json_file, options)
  65. end
  66. # Print summary
  67. 4 puts "-" * 60
  68. 4 puts "✅ Build Complete!"
  69. 4 puts " Generated: #{@generated_count} files"
  70. 4 puts " Failed: #{@failed_count} files" if @failed_count > 0
  71. 4 puts " Skipped: #{@skipped_count} files" if @skipped_count > 0
  72. 4 @failed_count == 0
  73. end
  74. 1 private
  75. 1 def clean_output_directory
  76. 1 puts "🧹 Cleaning output directory..."
  77. 1 if Dir.exist?(@output_dir)
  78. # Only remove generated XML files (those with our comment marker)
  79. 1 Dir.glob(File.join(@output_dir, '*.xml')).each do |file|
  80. 1 content = File.read(file)
  81. 1 if content.include?('<!-- Generated from') && content.include?('.json')
  82. 1 puts " Removing: #{File.basename(file)}"
  83. 1 File.delete(file)
  84. end
  85. end
  86. end
  87. end
  88. # Validate a JSON component and all its children recursively
  89. 1 def validate_json(json_data)
  90. 1 return [] unless json_data.is_a?(Hash)
  91. 1 warnings = @validator.validate(json_data)
  92. # Validate children recursively
  93. 1 children = json_data['child'] || json_data['children'] || []
  94. 1 children = [children] unless children.is_a?(Array)
  95. 1 children.each do |child|
  96. warnings.concat(validate_json(child)) if child.is_a?(Hash)
  97. end
  98. # Validate sections (for Collection/Table)
  99. 1 if json_data['sections'].is_a?(Array)
  100. json_data['sections'].each do |section|
  101. if section.is_a?(Hash)
  102. ['header', 'footer', 'cell'].each do |key|
  103. warnings.concat(validate_json(section[key])) if section[key].is_a?(Hash)
  104. end
  105. end
  106. end
  107. end
  108. 1 warnings
  109. end
  110. 1 def process_layout(json_file, options = {})
  111. 4 layout_name = File.basename(json_file, '.json')
  112. # Skip partial/included files (convention: starts with underscore)
  113. 4 if layout_name.start_with?('_')
  114. 1 puts " ⏭️ Skipping partial: #{layout_name}"
  115. 1 @skipped_count += 1
  116. 1 return
  117. end
  118. # Skip cell templates (they're used in collections)
  119. 3 if layout_name.end_with?('_cell') || layout_name.include?('cell')
  120. 1 puts " ⏭️ Skipping cell template: #{layout_name}"
  121. 1 @skipped_count += 1
  122. 1 return
  123. end
  124. # Skip included files (used by include mechanism)
  125. 2 if layout_name.start_with?('included')
  126. puts " ⏭️ Skipping include file: #{layout_name}"
  127. @skipped_count += 1
  128. return
  129. end
  130. 2 print " 📝 Processing: #{layout_name}..."
  131. begin
  132. # Validate JSON if enabled
  133. 2 if @validation_enabled && @validator
  134. 1 json_content = File.read(json_file)
  135. 1 json_data = JSON.parse(json_content)
  136. 1 warnings = validate_json(json_data)
  137. 1 if warnings.any?
  138. 1 puts " ⚠️ #{warnings.length} warning(s)"
  139. 1 @validation_callback&.call(layout_name, warnings)
  140. end
  141. end
  142. # Ensure project_path is set in config
  143. 2 config_with_path = @config.merge('project_path' => @project_path)
  144. # Generate XML using the existing generator
  145. 2 generator = XmlGenerator::Generator.new(layout_name, config_with_path)
  146. 2 if generator.generate
  147. 2 @generated_count += 1
  148. 2 puts " ✅" unless @validation_enabled && warnings&.any?
  149. else
  150. @failed_count += 1
  151. puts " ❌"
  152. end
  153. rescue JSON::ParserError => e
  154. @failed_count += 1
  155. puts " ❌"
  156. puts " JSON Parse Error: #{e.message}"
  157. rescue => e
  158. @failed_count += 1
  159. puts " ❌"
  160. puts " Error: #{e.message}"
  161. puts e.backtrace.first(5).map { |line| " #{line}" }.join("\n")
  162. end
  163. end
  164. end
  165. end
  166. end
  167. # Allow running directly
  168. 1 if __FILE__ == $0
  169. require_relative '../core/config_manager'
  170. config = KjuiTools::Core::ConfigManager.load_config
  171. builder = KjuiTools::Xml::XmlBuilder.new(config)
  172. options = {}
  173. ARGV.each do |arg|
  174. case arg
  175. when '--clean', '-c'
  176. options[:clean] = true
  177. when '--debug', '-d'
  178. config['debug'] = true
  179. when '--validate', '-v'
  180. builder.validation_enabled = true
  181. end
  182. end
  183. builder.build(options)
  184. end

lib/xml/xml_generator.rb

74.16% lines covered

209 relevant lines. 155 lines covered and 54 lines missed.
    
  1. #!/usr/bin/env ruby
  2. 1 require 'json'
  3. 1 require 'fileutils'
  4. 1 require 'set'
  5. 1 require 'nokogiri'
  6. 1 require_relative '../core/json_loader'
  7. 1 require_relative '../core/style_loader'
  8. 1 require_relative 'helpers/component_mapper'
  9. 1 require_relative 'helpers/attribute_mapper'
  10. 1 require_relative 'helpers/binding_parser'
  11. 1 require_relative 'helpers/layout_attribute_processor'
  12. 1 require_relative 'helpers/data_binding_helper'
  13. 1 require_relative 'drawable/drawable_generator'
  14. 1 require_relative 'resources/string_resource_manager'
  15. 1 module XmlGenerator
  16. 1 class Generator
  17. 1 def initialize(layout_name, config, options = {})
  18. 24 @layout_name = layout_name
  19. 24 @config = config
  20. 24 @options = options
  21. 24 @json_loader = JsonLoader.new(config)
  22. 24 @style_loader = StyleLoader.new(config)
  23. 24 @component_mapper = ComponentMapper.new
  24. # Initialize resource managers
  25. 24 project_root = @config['project_path']
  26. 24 @drawable_generator = DrawableGenerator::Generator.new(project_root)
  27. 24 @string_resource_manager = Resources::StringResourceManager.new(project_root)
  28. 24 @attribute_mapper = AttributeMapper.new(@drawable_generator, @string_resource_manager)
  29. 24 @binding_parser = BindingParser.new
  30. 24 @layout_processor = LayoutAttributeProcessor.new(@attribute_mapper)
  31. # Get package name from config or auto-detect
  32. 24 @package_name = @config['package_name'] || detect_package_name
  33. # Allow custom output filename
  34. 24 @output_filename = options[:output_filename]
  35. end
  36. 1 def generate
  37. 4 puts "Generating XML for #{@layout_name}..."
  38. # Load JSON
  39. 4 json_content = @json_loader.load_layout(@layout_name)
  40. 4 if json_content.nil?
  41. 1 puts "Error: Could not load layout #{@layout_name}"
  42. 1 return false
  43. end
  44. # Parse JSON
  45. 3 layout_data = JSON.parse(json_content)
  46. # Apply styles
  47. 3 layout_data = @style_loader.apply_styles(layout_data)
  48. # Generate XML
  49. 3 xml_content = generate_xml(layout_data)
  50. # Save XML file
  51. 3 save_xml(xml_content)
  52. # Save any new strings to strings.xml
  53. 3 @string_resource_manager.save_new_strings
  54. 3 true
  55. rescue => e
  56. puts "Error generating XML: #{e.message}"
  57. puts " Backtrace:"
  58. e.backtrace[0..4].each { |line| puts " #{line}" }
  59. false
  60. end
  61. 1 private
  62. 1 def detect_package_name
  63. # Try to detect from AndroidManifest.xml
  64. manifest_paths = [
  65. File.join(@config['project_path'], 'src', 'main', 'AndroidManifest.xml'),
  66. File.join(@config['project_path'], 'app', 'src', 'main', 'AndroidManifest.xml')
  67. ]
  68. manifest_paths.each do |path|
  69. if File.exist?(path)
  70. content = File.read(path)
  71. if content =~ /package="([^"]+)"/
  72. return $1
  73. end
  74. end
  75. end
  76. # Try to detect from build.gradle
  77. gradle_paths = [
  78. File.join(@config['project_path'], 'build.gradle'),
  79. File.join(@config['project_path'], 'app', 'build.gradle'),
  80. File.join(@config['project_path'], 'build.gradle.kts'),
  81. File.join(@config['project_path'], 'app', 'build.gradle.kts')
  82. ]
  83. gradle_paths.each do |path|
  84. if File.exist?(path)
  85. content = File.read(path)
  86. # Look for namespace
  87. if content =~ /namespace\s*[=:]\s*["']([^"']+)["']/
  88. return $1
  89. end
  90. # Look for applicationId
  91. if content =~ /applicationId\s*[=:]\s*["']([^"']+)["']/
  92. return $1
  93. end
  94. end
  95. end
  96. # Default
  97. 'com.example.app'
  98. end
  99. 1 def generate_xml(json_data)
  100. # Check if layout uses data binding
  101. 3 has_binding = check_for_bindings(json_data)
  102. 3 if has_binding
  103. generate_data_binding_xml(json_data)
  104. else
  105. 3 generate_regular_xml(json_data)
  106. end
  107. end
  108. 1 def check_for_bindings(json_data)
  109. # Recursively check for @{} syntax in the JSON
  110. 5 json_string = json_data.to_json
  111. 5 json_string.include?('@{')
  112. end
  113. 1 def generate_data_binding_xml(json_data)
  114. # Extract all binding variables
  115. variables = extract_binding_variables(json_data)
  116. builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
  117. xml.comment " Generated from #{@layout_name}.json with Data Binding "
  118. xml.comment " DO NOT EDIT MANUALLY - Use 'kjui generate' to update "
  119. # Create layout root for data binding
  120. xml.layout('xmlns:android' => 'http://schemas.android.com/apk/res/android',
  121. 'xmlns:app' => 'http://schemas.android.com/apk/res-auto',
  122. 'xmlns:tools' => 'http://schemas.android.com/tools') do
  123. # Add data section
  124. xml.data do
  125. # Add common imports
  126. xml.import(type: 'android.view.View')
  127. # Add data variable
  128. if has_data_definitions?(json_data)
  129. data_class = "#{camelize(@layout_name)}Data"
  130. xml.variable(name: 'data', type: "#{@package_name}.data.#{data_class}")
  131. end
  132. # Add viewModel variable if there are onClick handlers
  133. if has_click_handlers?(json_data)
  134. view_model_class = "#{camelize(@layout_name)}ViewModel"
  135. xml.variable(name: 'viewModel', type: "#{@package_name}.viewmodels.#{view_model_class}")
  136. end
  137. end
  138. # Add the actual layout content
  139. # Pass false for is_root since namespaces are already on <layout> tag
  140. create_xml_element(xml, json_data, false)
  141. end
  142. end
  143. # Format the XML nicely
  144. doc = Nokogiri::XML(builder.to_xml) do |config|
  145. config.default_xml.noblanks
  146. end
  147. # Pretty print with proper indentation
  148. formatted_xml = doc.to_xml(
  149. indent: 4,
  150. indent_text: ' ',
  151. save_with: Nokogiri::XML::Node::SaveOptions::FORMAT |
  152. Nokogiri::XML::Node::SaveOptions::AS_XML
  153. )
  154. # Additional formatting: put each attribute on a new line for better readability
  155. format_attributes(formatted_xml)
  156. end
  157. 1 def generate_regular_xml(json_data)
  158. 3 builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
  159. 3 xml.comment " Generated from #{@layout_name}.json "
  160. 3 xml.comment " DO NOT EDIT MANUALLY - Use 'kjui generate' to update "
  161. # Create root layout
  162. 3 create_xml_element(xml, json_data, true)
  163. end
  164. # Format the XML nicely
  165. 3 doc = Nokogiri::XML(builder.to_xml) do |config|
  166. 3 config.default_xml.noblanks
  167. end
  168. # Pretty print with proper indentation
  169. 3 formatted_xml = doc.to_xml(
  170. indent: 4,
  171. indent_text: ' ',
  172. save_with: Nokogiri::XML::Node::SaveOptions::FORMAT |
  173. Nokogiri::XML::Node::SaveOptions::AS_XML
  174. )
  175. # Additional formatting: put each attribute on a new line for better readability
  176. 3 format_attributes(formatted_xml)
  177. end
  178. 1 def extract_binding_variables(json_data)
  179. 2 variables = Set.new
  180. 2 extract_variables_recursive(json_data, variables)
  181. 2 variables
  182. end
  183. 1 def extract_variables_recursive(data, variables)
  184. 5 if data.is_a?(Hash)
  185. 4 data.each do |key, value|
  186. 5 if value.is_a?(String) && value.start_with?('@{')
  187. # Extract variable name from binding expression
  188. 3 if value.match(/@\{([^}]+)\}/)
  189. 3 expr = $1
  190. # Simple variable extraction (can be enhanced)
  191. 3 if expr.match(/^(\w+)/)
  192. 3 variables.add($1)
  193. end
  194. end
  195. 2 elsif value.is_a?(Hash) || value.is_a?(Array)
  196. 1 extract_variables_recursive(value, variables)
  197. end
  198. end
  199. 1 elsif data.is_a?(Array)
  200. 3 data.each { |item| extract_variables_recursive(item, variables) }
  201. end
  202. end
  203. 1 def has_click_handlers?(json_data)
  204. 3 json_string = json_data.to_json
  205. 3 json_string.include?('"onClick"') || json_string.include?('"onclick"')
  206. end
  207. 1 def camelize(snake_case)
  208. 2 snake_case.split('_').map(&:capitalize).join
  209. end
  210. 1 def needs_tools_namespace?(json_element)
  211. # Check if this element or any of its children use tools attributes
  212. 6 json_string = json_element.to_json
  213. 6 json_string.include?('"tools:') || json_string.include?('"title"') || json_string.include?('"count"')
  214. end
  215. 1 def has_data_definitions?(json_data)
  216. # Check if there are any data definitions anywhere in the JSON structure
  217. 3 return true if json_data['data']
  218. # Check children recursively
  219. 2 if json_data['child']
  220. 1 children = json_data['child'].is_a?(Array) ? json_data['child'] : [json_data['child']]
  221. 1 children.each do |child|
  222. 1 return true if child.is_a?(Hash) && child['data']
  223. return true if child.is_a?(Hash) && has_data_definitions?(child)
  224. end
  225. end
  226. 1 if json_data['children']
  227. children = json_data['children'].is_a?(Array) ? json_data['children'] : [json_data['children']]
  228. children.each do |child|
  229. return true if child.is_a?(Hash) && child['data']
  230. return true if child.is_a?(Hash) && has_data_definitions?(child)
  231. end
  232. end
  233. 1 false
  234. end
  235. 1 def create_xml_element(xml, json_element, is_root = false, parent_orientation = nil, parent_type = nil)
  236. # Map JSON type to Android view class (pass json_element for View type checking)
  237. 5 view_class = @component_mapper.map_component(json_element['type'], json_element)
  238. # Prepare all attributes first
  239. 5 attrs = {}
  240. # Add namespace declarations if this is the root element
  241. 5 if is_root
  242. 3 attrs['xmlns:android'] = 'http://schemas.android.com/apk/res/android'
  243. # Always add app namespace as it's commonly needed for ConstraintLayout and custom attributes
  244. 3 attrs['xmlns:app'] = 'http://schemas.android.com/apk/res-auto'
  245. # Add tools namespace if we're using tools attributes
  246. 3 if needs_tools_namespace?(json_element)
  247. attrs['xmlns:tools'] = 'http://schemas.android.com/tools'
  248. end
  249. end
  250. # Add ID if present
  251. 5 if json_element['id']
  252. attrs['android:id'] = "@+id/#{json_element['id']}"
  253. end
  254. # Process layout dimensions
  255. 5 dimension_attrs = @layout_processor.process_dimensions(json_element, is_root, parent_orientation)
  256. 5 attrs.merge!(dimension_attrs)
  257. # Process orientation for LinearLayout
  258. 5 orientation_attrs = @layout_processor.process_orientation(view_class, json_element)
  259. 5 attrs.merge!(orientation_attrs)
  260. # Process all other attributes
  261. 5 other_attrs = @layout_processor.process_attributes(json_element, parent_type)
  262. 5 attrs.merge!(other_attrs)
  263. # Determine orientation for children
  264. 5 current_orientation = nil
  265. 5 if view_class == 'LinearLayout'
  266. current_orientation = json_element['orientation'] || 'vertical'
  267. end
  268. # Create element with attributes
  269. # For custom views with package name, use the full class name
  270. 5 if view_class.include?('.')
  271. # Custom view with package name - create element directly
  272. 5 xml.send(:method_missing, view_class, attrs) do
  273. 5 create_children(xml, json_element, current_orientation, view_class)
  274. end
  275. else
  276. # Standard Android view
  277. xml.send(view_class, attrs) do
  278. create_children(xml, json_element, current_orientation, view_class)
  279. end
  280. end
  281. end
  282. 1 def create_children(parent_element, json_element, parent_orientation = nil, parent_type = nil)
  283. # Handle children
  284. 5 children = json_element['children'] || json_element['child']
  285. 5 return unless children
  286. 3 children = [children] unless children.is_a?(Array)
  287. 3 children.each do |child|
  288. # Skip data definitions - they don't create UI elements
  289. 2 next if child.is_a?(Hash) && child.key?('data') && !child.key?('type')
  290. 2 create_xml_element(parent_element, child, false, parent_orientation, parent_type)
  291. end
  292. end
  293. 1 def format_attributes(xml_string)
  294. # Format XML to put each attribute on its own line for better readability
  295. 6 lines = xml_string.split("\n")
  296. 6 formatted_lines = []
  297. 6 lines.each do |line|
  298. # Skip comments and empty lines
  299. 19 if line.strip.start_with?('<!--') || line.strip.start_with?('<?xml') || line.strip.empty?
  300. 11 formatted_lines << line
  301. 11 next
  302. end
  303. # Check if line contains an XML tag with attributes
  304. 8 if line =~ /^(\s*)<([^\/\s>]+)(.*?)(\s*\/?>.*?)$/
  305. 6 indent = $1
  306. 6 tag_name = $2
  307. 6 attributes_str = $3
  308. 6 tag_end = $4
  309. # Parse all attributes including namespace prefixes
  310. 6 attributes = []
  311. 6 attributes_str.scan(/(\S+?)="([^"]*)"/) do |attr_name, attr_value|
  312. 23 attributes << [attr_name, attr_value]
  313. end
  314. # Format based on number of attributes
  315. 6 if attributes.size > 1
  316. # Multiple attributes - put each on its own line
  317. 5 formatted_lines << "#{indent}<#{tag_name}"
  318. 5 attributes.each do |attr_name, attr_value|
  319. 22 formatted_lines << "#{indent} #{attr_name}=\"#{attr_value}\""
  320. end
  321. # Handle closing tag
  322. 5 if tag_end.strip == '/>'
  323. 3 formatted_lines[-1] += '/>'
  324. 2 elsif tag_end.include?('>')
  325. # Check if there's content after the >
  326. 2 if tag_end =~ />\s*(.+)$/
  327. content = $1
  328. formatted_lines[-1] += '>'
  329. # Add the content on the same line if it's simple text
  330. if content && !content.empty?
  331. formatted_lines[-1] += content
  332. end
  333. else
  334. 2 formatted_lines[-1] += '>'
  335. end
  336. else
  337. formatted_lines[-1] += tag_end.strip
  338. end
  339. 1 elsif attributes.size == 1
  340. # Single attribute - can stay on one line
  341. 1 formatted_lines << line
  342. else
  343. # No attributes
  344. formatted_lines << line
  345. end
  346. else
  347. # Not a tag line or closing tag
  348. 2 formatted_lines << line
  349. end
  350. end
  351. 6 formatted_lines.join("\n")
  352. end
  353. 1 def save_xml(xml_content)
  354. # Determine output path
  355. 3 output_dir = File.join(@config['project_path'], 'src', 'main', 'res', 'layout')
  356. 3 output_dir = File.join(@config['project_path'], 'app', 'src', 'main', 'res', 'layout') if File.exist?(File.join(@config['project_path'], 'app'))
  357. 3 FileUtils.mkdir_p(output_dir)
  358. # Use custom filename if provided, otherwise use default
  359. 3 filename = @output_filename || "#{@layout_name.downcase}.xml"
  360. 3 output_file = File.join(output_dir, filename)
  361. # Save XML file
  362. 3 File.write(output_file, xml_content)
  363. 3 puts "✅ Generated: #{output_file}"
  364. end
  365. end
  366. end